fix: repair structured date validator migration

This commit is contained in:
Jesse_Chen
2026-07-25 13:51:54 +08:00
parent e41fae1e05
commit 3e56389aeb
4 changed files with 156 additions and 5 deletions
@@ -67,6 +67,7 @@ jobs:
frontend/supabase/migrations/20260724020000_align_global_birthplace_rectification_contract.sql \
frontend/supabase/migrations/20260724030000_allow_assistant_only_rectification_regenerate.sql \
frontend/supabase/migrations/20260725010000_structured_conversational_date_confirmation.sql \
frontend/supabase/migrations/20260725020000_repair_structured_conversational_date_validator.sql \
"$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_DIR/"
- name: Check or apply reviewed migrations
@@ -118,7 +119,8 @@ jobs:
"$REMOTE_DIR/20260724010000_global_birth_locations.sql" \
"$REMOTE_DIR/20260724020000_align_global_birthplace_rectification_contract.sql" \
"$REMOTE_DIR/20260724030000_allow_assistant_only_rectification_regenerate.sql" \
"$REMOTE_DIR/20260725010000_structured_conversational_date_confirmation.sql"
"$REMOTE_DIR/20260725010000_structured_conversational_date_confirmation.sql" \
"$REMOTE_DIR/20260725020000_repair_structured_conversational_date_validator.sql"
do
filename="$(basename "$sql_file")"
checksum="$(sha256sum "$sql_file" | awk '{print $1}')"
+15
View File
@@ -1338,3 +1338,18 @@
- 防复发:新增需要生产执行的 migration 时,必须同时更新受审生产迁移清单并由测试覆盖上传和执行两处引用。
- 相关记录:BUG-068、BUG-069
- 修复版本:待提交(本地可测)
## BUG-071 | 结构化日期校验器的冗余正则在 PostgreSQL 执行时报错
- 状态:resolved
- 首次发现:2026-07-25
- 最近更新:2026-07-25
- 影响面:生产环境生时校正首次建案、`proposedDate` 持久化与预留点数释放
- 用户现象:开始生时校正时接口返回 `409 action_conflict` 和“请加载最新进度后再试”,预留点数随后自动释放且没有创建 case。
- 触发条件:首轮 Agent 生成带 `followUp.answerMode=yes_no``followUp.proposedDate` 的 evidence request,数据库开始执行结构化日期校验。
- 根因:`20260725010000_structured_conversational_date_confirmation.sql` 同时保留了一个冗余的聚合日期正则;该正则括号不平衡,PostgreSQL 在函数运行时抛出 `2201B invalid regular expression`。下方按 `year/month/day` 精度分别校验的正则已经完整覆盖格式约束。
- 修复:新增向前迁移 `20260725020000_repair_structured_conversational_date_validator.sql`,删除冗余聚合正则,只保留现有精度分支;同步加入生产迁移工作流。
- 验证:生产事务回滚 dry-run 通过;以 `2020-10 + month + yes_no` 调用校验器返回 true;迁移账本、函数定义和生产健康状态均复核。
- 防复发:数据库 JSON 合同的日期格式只维护一组精度分支;新增生产迁移时必须执行真实 PostgreSQL 函数调用,而不是只做 SQL 文本断言。
- 相关记录:BUG-067、BUG-070
- 修复版本:本次修复提交(生产已向前迁移)
@@ -0,0 +1,107 @@
begin;
-- Repair the structured date validator after the initial migration shipped a
-- redundant aggregate date regex with unbalanced parentheses. The precision-
-- specific checks below are the single source of truth.
create or replace function public.conversational_rectification_valid_evidence_request(
p_value jsonb
)
returns boolean
language sql
immutable
strict
set search_path = ''
as $$
select pg_catalog.jsonb_typeof(p_value) = 'object'
and pg_catalog.octet_length(p_value::text) <= 2048
and public.conversational_rectification_numbers_are_stable(p_value)
and public.conversational_rectification_has_only_keys(
p_value,
array['domains', 'datePrecision', 'freeTextAllowed', 'prompt', 'followUp']::text[]
)
and p_value ?& array['domains', 'datePrecision', 'freeTextAllowed']::text[]
and pg_catalog.jsonb_typeof(p_value -> 'domains') = 'array'
and pg_catalog.jsonb_array_length(p_value -> 'domains') between 1 and 4
and not exists (
select 1
from pg_catalog.jsonb_array_elements(p_value -> 'domains') domain
where pg_catalog.jsonb_typeof(domain) is distinct from 'string'
or domain #>> '{}' not in (
'career', 'education', 'finance', 'health_pressure', 'relocation',
'relationship', 'family', 'other'
)
)
and pg_catalog.jsonb_typeof(p_value -> 'datePrecision') = 'string'
and p_value ->> 'datePrecision' in ('month_preferred', 'year_accepted')
and pg_catalog.jsonb_typeof(p_value -> 'freeTextAllowed') = 'boolean'
and p_value -> 'freeTextAllowed' = 'true'::jsonb
and (
not (p_value ? 'prompt')
or (
pg_catalog.jsonb_typeof(p_value -> 'prompt') = 'string'
and pg_catalog.char_length(pg_catalog.btrim(p_value ->> 'prompt')) between 1 and 1000
)
)
and (
not (p_value ? 'followUp')
or (
pg_catalog.jsonb_typeof(p_value -> 'followUp') = 'object'
and public.conversational_rectification_has_only_keys(
p_value -> 'followUp',
array['kind', 'evidenceId', 'answerMode', 'proposedDate']::text[]
)
and (p_value -> 'followUp') ?& array['kind', 'evidenceId']::text[]
and p_value #>> '{followUp,kind}' in ('new_event', 'event_date', 'event_detail')
and (
not ((p_value -> 'followUp') ? 'answerMode')
or (
pg_catalog.jsonb_typeof(p_value #> '{followUp,answerMode}') = 'string'
and p_value #>> '{followUp,answerMode}' in ('free_text', 'yes_no')
)
)
and (
not ((p_value -> 'followUp') ? 'proposedDate')
or p_value #> '{followUp,proposedDate}' = 'null'::jsonb
or (
pg_catalog.jsonb_typeof(p_value #> '{followUp,proposedDate}') = 'object'
and public.conversational_rectification_has_only_keys(
p_value #> '{followUp,proposedDate}',
array['value', 'precision']::text[]
)
and (p_value #> '{followUp,proposedDate}') ?& array['value', 'precision']::text[]
and pg_catalog.jsonb_typeof(p_value #> '{followUp,proposedDate,value}') = 'string'
and pg_catalog.jsonb_typeof(p_value #> '{followUp,proposedDate,precision}') = 'string'
and (
(p_value #>> '{followUp,proposedDate,precision}' = 'year'
and p_value #>> '{followUp,proposedDate,value}' ~ '^[0-9]{4}$')
or (p_value #>> '{followUp,proposedDate,precision}' = 'month'
and p_value #>> '{followUp,proposedDate,value}' ~ '^[0-9]{4}-((0[1-9])|(1[0-2]))$')
or (p_value #>> '{followUp,proposedDate,precision}' = 'day'
and p_value #>> '{followUp,proposedDate,value}'
~ '^[0-9]{4}-((0[1-9])|(1[0-2]))-((0[1-9])|([12][0-9])|(3[01]))$')
)
)
)
and (
(p_value #>> '{followUp,kind}' = 'new_event'
and p_value #>> '{followUp,evidenceId}' is null)
or (p_value #>> '{followUp,kind}' <> 'new_event'
and public.conversational_rectification_valid_uuid_text(
p_value #>> '{followUp,evidenceId}'
))
)
and (
(p_value #>> '{followUp,answerMode}' = 'yes_no'
and p_value #>> '{followUp,kind}' = 'event_date'
and pg_catalog.jsonb_typeof(p_value #> '{followUp,proposedDate}') = 'object')
or (p_value #>> '{followUp,answerMode}' is distinct from 'yes_no'
and (
not ((p_value -> 'followUp') ? 'proposedDate')
or p_value #> '{followUp,proposedDate}' = 'null'::jsonb
))
)
)
);
$$;
commit;
@@ -44,6 +44,13 @@ const structuredDateConfirmationMigration = readFileSync(
),
"utf8",
);
const repairedStructuredDateValidatorMigration = readFileSync(
new URL(
"../supabase/migrations/20260725020000_repair_structured_conversational_date_validator.sql",
import.meta.url,
),
"utf8",
);
const productionMigrationWorkflow = readFileSync(
new URL(
"../../.github/workflows/apply-production-rectification-migrations.yml",
@@ -139,9 +146,29 @@ test("durable evidence requests persist strict structured date confirmation", ()
assert.match(structuredDateConfirmationMigration, /valid_uuid_text/);
});
test("production workflow uploads and applies the structured date confirmation migration", () => {
assert.equal(
productionMigrationWorkflow.match(/20260725010000_structured_conversational_date_confirmation\.sql/g)?.length,
2,
test("repaired structured date validator relies on precision-specific date checks", () => {
assert.doesNotMatch(
repairedStructuredDateValidatorMigration,
/\^\[0-9\]\{4\}\(-/,
);
assert.match(
repairedStructuredDateValidatorMigration,
/proposedDate,precision}' = 'month'/,
);
assert.match(
repairedStructuredDateValidatorMigration,
/proposedDate,precision}' = 'day'/,
);
});
test("production workflow uploads and applies the structured date confirmation migrations", () => {
for (const migration of [
"20260725010000_structured_conversational_date_confirmation",
"20260725020000_repair_structured_conversational_date_validator",
]) {
assert.equal(
productionMigrationWorkflow.match(new RegExp(`${migration}\\.sql`, "g"))?.length,
2,
);
}
});