Skip to content

Codex/chat topic scope trust tier - #2

Merged
quangbmt114 merged 7 commits into
mainfrom
codex/chat-topic-scope-trust-tier
Aug 1, 2026
Merged

Codex/chat topic scope trust tier#2
quangbmt114 merged 7 commits into
mainfrom
codex/chat-topic-scope-trust-tier

Conversation

@quangbmt114

@quangbmt114 quangbmt114 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

Release Notes

  • New Features

    • Chat assistant now supports traffic, online safety, and copyright topics with clearer out-of-scope responses.
    • Added official safety and legal guidance content for import.
    • Admin dashboard displays draft details, intake content, publication eligibility, and citation effectivity notes.
    • Web search now supports referenced legal details with appropriate warnings.
  • Bug Fixes

    • Prevented unsupported or ineligible content from entering retrieval, persistence, or editing workflows.
    • Improved retry messaging for temporary search-provider outages.
  • Documentation

    • Updated product requirements and technical guidance for topic scope and official content policies.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds deterministic topic scoping for chat, trusted search validation, official corpus draft import, topic-aware candidate handling, guarded admin workflows, and updated UI, tests, and documentation.

Changes

Chat scope and trusted answers

Layer / File(s) Summary
Topic classification and managed answers
lib/chat-topic-scope.ts, lib/legal-chat.ts, tests/chat-topic-scope.test.mjs, tests/rendered-html.test.mjs
Chat supports traffic, online safety, and copyright topics. Managed answers use topic filtering. Account-compromise questions receive safety guidance without legal claims.
Chat retrieval and persistence controls
app/api/chat/route.ts, lib/chat-answer-presentation.ts, lib/web-search-candidates.ts, tests/chat-topic-scope.test.mjs, tests/web-search-candidates.test.mjs
The route rejects out-of-scope and topic-mismatched results. Reference answers use reduced sections. Only eligible official results persist as candidates.
Web-search policy
lib/openai-web-search.ts, .env.example, tests/openai-web-search.test.mjs, scripts/smoke-openai-web-search.mjs
Direct search accepts syntactically valid model IDs, reports provider origins, supports warned legal details, and uses a 30-second timeout.

Official corpus import

Layer / File(s) Summary
Corpus validation and import
lib/official-corpus-drafts.ts, fixtures/rag/official-corpus-drafts.v1.json, scripts/import-official-corpus-drafts.mjs, package.json, tests/official-corpus-drafts.test.mjs
The PR adds seven draft records, strict schema and citation validation, deterministic hashing, idempotent reconciliation, legacy binding, transactional writes, and a local import command.

Admin and product scope

Layer / File(s) Summary
Publication guards and citation editing
app/admin/AdminDashboard.tsx, app/admin/api/web-search-candidates/route.ts
Admin responses expose intake drafts and publication eligibility. The dashboard falls back to intake content and disables workflow actions for ineligible candidates. Citation editors support effectivity notes.
Scope documentation and UI copy
app/page.tsx, AGENTS.md, docs/PRODUCT_REQUIREMENTS.md, docs/TECHNICAL_SPEC.md, docs/USER_STORIES.md, docs/PROGRESS.md
The UI and project documents describe supported topics, trust rules, corpus policies, model policies, timeout behavior, and verification records.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers: tomnyson

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ChatRoute
  participant TopicClassifier
  participant ManagedAnswers
  participant WebSearch
  participant CandidateStore

  Client->>ChatRoute: Submit question
  ChatRoute->>TopicClassifier: Classify topic
  TopicClassifier-->>ChatRoute: Scope decision
  ChatRoute->>ManagedAnswers: Query topic-matched answer
  ManagedAnswers-->>ChatRoute: Answer or no match
  ChatRoute->>WebSearch: Request validated result
  WebSearch-->>ChatRoute: Provider result
  ChatRoute->>CandidateStore: Persist eligible official result
  CandidateStore-->>ChatRoute: Persistence result
  ChatRoute-->>Client: Render scoped answer
Loading

Poem

A rabbit checks each topic gate,
Then guides safe answers through the state.
Drafts gain hashes, sources align,
Trusted notes pass the review line.
Ineligible edits wait below,
While careful tests make carrots grow.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main changes involving chat topic scope and trust tiers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/admin/AdminDashboard.tsx`:
- Around line 431-440: Add type="button" to every button rendered in the
admin-actions block of the candidate card, including the Biên tập, Gửi duyệt,
Duyệt & đưa vào RAG, Từ chối, and Lưu trữ buttons, while preserving their
existing handlers and conditions.

In `@app/admin/api/web-search-candidates/route.ts`:
- Around line 87-116: The intakeDraft handling in the event-processing loop must
validate metadata.intakeDraft against the CandidateSnapshot draft shape,
including required citations and tags arrays and their expected field types,
before assigning it to intakeDraft or deriving intakeTitle and intakeAnswer.
Leave intakeDraft null for malformed drafts; the existing startEdit usage in
app/admin/AdminDashboard.tsx at line 309 requires no direct change.
- Around line 188-214: The PATCH mutation guard currently scans only the top-200
events from listWebSearchCandidates, allowing out-of-window ineligible
candidates through. Replace the eligibility lookup around publicationBlocked
with a targeted query against web_search_candidate_events filtered by
candidate_id, and preserve the metadata parsing and 409 response when
publicationEligible is false before save_revision or workflow transitions.

In `@docs/PRODUCT_REQUIREMENTS.md`:
- Around line 26-29: Update the MVP scope message in PRODUCT_REQUIREMENTS.md to
replace the outdated “mạng xã hội” and “bản quyền” terms with the renamed topics
“An toàn và ứng xử trên mạng” and “Bản quyền học đường,” matching Section 6,
FR-04, and the chat-panel wording.

In `@lib/official-corpus-drafts.ts`:
- Around line 833-849: Update the binding-upgrade insert within the
missingSourceIndexes mapping to apply the same isOfficialGovernmentHost
validation used by the new-record path before writing
web_search_candidate_sources.official_host. Preserve the existing insert
behavior for official hosts and reject or handle non-official hosts consistently
with the new-record flow.
- Around line 442-448: Scope the superseded-content guard around the
JSON.stringify regex check to the affected intent, using the existing
online_safety.false_or_insulting_content rule or the topic === "online_safety"
condition. Preserve the current null return and matching patterns, while
allowing unrelated records with these phrases to remain valid.
- Around line 743-774: Update the legacy binding path around `legacyMatches` and
`bindingUpgrades` to compare the stored candidate answer with
`record.proposedSnapshot.answer`; return `{ ok: false, reason: "conflict" }`
when they differ, otherwise update `web_search_candidates.initial_answer_text`
and `content_sha256` to the current answer and hash before binding missing
sources. Preserve the existing source-binding and event behavior.
- Around line 49-78: Document the provenance of each entry in
legacyContentHashes by adding comments that identify the canonical input
legacyCanonical(current.initial_answer_text, current.provider_model,
existingSources.map(source => source.official_url)) and the legacy packet
version/date that produced its digest. Keep the existing UUID-to-digest mappings
unchanged and associate each provenance note with the corresponding entry.
- Around line 566-568: Update eventId and both call sites near the event
creation flows to derive IDs using the prefix plus the full candidateId, without
removing its first character. Verify the id column supports the resulting length
and add a migration for rows generated by the current truncated scheme.

In `@lib/web-search-candidates.ts`:
- Around line 78-93: Update subtypeSql used by findReviewedWebCandidate to apply
the same Vietnamese diacritic-stripping and case-normalization as
reviewedCandidateMatchesChatTopic, or remove the SQL tag filter so the JS check
handles matching consistently. Add a positive findReviewedWebCandidate test with
a stored candidate tagged using a copyright variant such as BẢN QUYỀN, verifying
it is returned.

In `@package.json`:
- Line 28: Raise the package.json engines.node minimum from 22.13.0 to at least
22.15.0 so the corpus:import:local script using module.registerHooks() runs on
every supported Node.js version.

In `@scripts/import-official-corpus-drafts.mjs`:
- Around line 24-61: Extract PreparedStatement, LocalD1Adapter, and the
duplicated registerHooks resolve hook into a shared module such as
lib/local-d1-adapter.mjs; update scripts/import-official-corpus-drafts.mjs lines
24-61 to import and use it. In tests/official-corpus-drafts.test.mjs lines
55-94, delete the local Prepared and D1Adapter implementations and import the
shared adapter so both consumers independently preserve the D1Like contract and
success flag.

In `@tests/chat-topic-scope.test.mjs`:
- Around line 245-283: Update every provider test identified in this file,
including the test containing `official-looking output with the wrong source
kind is never persisted`, to register worker-environment cleanup through the
test context’s failure-safe cleanup hook. Move each
`globalThis.__topicScopeWorkerEnv` key deletion into that hook and remove the
corresponding trailing delete loop from the test body, preserving the existing
test setup and assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2913a81d-47e6-4bf4-b381-2e6ec4f078e5

📥 Commits

Reviewing files that changed from the base of the PR and between 8c699ce and 4143c4e.

📒 Files selected for processing (23)
  • .env.example
  • app/admin/AdminDashboard.tsx
  • app/admin/api/web-search-candidates/route.ts
  • app/api/chat/route.ts
  • app/page.tsx
  • docs/PRODUCT_REQUIREMENTS.md
  • docs/PROGRESS.md
  • docs/TECHNICAL_SPEC.md
  • docs/USER_STORIES.md
  • fixtures/rag/official-corpus-drafts.v1.json
  • lib/chat-answer-presentation.ts
  • lib/chat-topic-scope.ts
  • lib/legal-chat.ts
  • lib/official-corpus-drafts.ts
  • lib/openai-web-search.ts
  • lib/web-search-candidates.ts
  • package.json
  • scripts/import-official-corpus-drafts.mjs
  • tests/chat-topic-scope.test.mjs
  • tests/official-corpus-drafts.test.mjs
  • tests/openai-web-search.test.mjs
  • tests/rendered-html.test.mjs
  • tests/web-search-candidates.test.mjs

Comment on lines +431 to +440
<h3>{candidate.snapshot?.title || candidate.intakeTitle || "Bản nháp chưa được biên tập"}</h3>
<p>{candidate.snapshot?.answer || candidate.intakeAnswer || candidate.initialAnswer}</p>
{!candidate.publicationEligible && <p><strong>Hướng dẫn an toàn MVP:</strong> nội dung này không đi vào kho căn cứ pháp lý.</p>}
{candidate.reviewReason && <p><strong>Lý do từ chối:</strong> {candidate.reviewReason}</p>}
<div className="admin-actions">
{canEdit && (candidate.status === "draft" || candidate.status === "rejected") && <button onClick={() => startEdit(candidate)}>Biên tập</button>}
{canEdit && candidate.status === "draft" && candidate.snapshot && candidate.editorPrincipalId === principalId && <button onClick={() => void action(candidate, "submit")}>Gửi duyệt</button>}
{canReview && candidate.status === "pending_review" && candidate.editorPrincipalId !== principalId && <button onClick={() => void action(candidate, "approve")}>Duyệt & đưa vào RAG</button>}
{canReview && candidate.status === "pending_review" && candidate.editorPrincipalId !== principalId && <button className="danger" onClick={() => void action(candidate, "reject")}>Từ chối</button>}
{canReview && candidate.status === "published" && <button onClick={() => void action(candidate, "archive")}>Lưu trữ</button>}
{candidate.publicationEligible && canEdit && (candidate.status === "draft" || candidate.status === "rejected") && <button onClick={() => startEdit(candidate)}>Biên tập</button>}
{candidate.publicationEligible && canEdit && candidate.status === "draft" && candidate.snapshot && candidate.editorPrincipalId === principalId && <button onClick={() => void action(candidate, "submit")}>Gửi duyệt</button>}
{candidate.publicationEligible && canReview && candidate.status === "pending_review" && candidate.editorPrincipalId !== principalId && <button onClick={() => void action(candidate, "approve")}>Duyệt & đưa vào RAG</button>}
{candidate.publicationEligible && canReview && candidate.status === "pending_review" && candidate.editorPrincipalId !== principalId && <button className="danger" onClick={() => void action(candidate, "reject")}>Từ chối</button>}
{candidate.publicationEligible && canReview && candidate.status === "published" && <button onClick={() => void action(candidate, "archive")}>Lưu trữ</button>}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Static analysis flags the buttons at Lines 436-440 for missing an explicit type attribute. These cards render as siblings of the editor <form>, not inside it, so there is no live risk of accidental form submission here. Setting type="button" explicitly is still good defensive practice in case the markup is restructured later.

🧰 Tools
🪛 React Doctor (0.9.1)

[warning] 436-436: Your users can submit the form by accident because a <button> with no type defaults to submit.

Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".

(button-has-type)


[warning] 437-437: Your users can submit the form by accident because a <button> with no type defaults to submit.

Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".

(button-has-type)


[warning] 438-438: Your users can submit the form by accident because a <button> with no type defaults to submit.

Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".

(button-has-type)


[warning] 439-439: Your users can submit the form by accident because a <button> with no type defaults to submit.

Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".

(button-has-type)


[warning] 440-440: Your users can submit the form by accident because a <button> with no type defaults to submit.

Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".

(button-has-type)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/admin/AdminDashboard.tsx` around lines 431 - 440, Add type="button" to
every button rendered in the admin-actions block of the candidate card,
including the Biên tập, Gửi duyệt, Duyệt & đưa vào RAG, Từ chối, and Lưu trữ
buttons, while preserving their existing handlers and conditions.

Source: Linters/SAST tools

Comment on lines +87 to +116
let intakeDraft: unknown = null;
let publicationEligible = true;
let intakeTitle = "";
let intakeAnswer = "";
for (const event of eventsByCandidate.get(id) ?? []) {
if (typeof event.metadata_json !== "string") continue;
try {
const metadata = JSON.parse(event.metadata_json) as Record<
string,
unknown
>;
if (
metadata.intakeDraft &&
typeof metadata.intakeDraft === "object" &&
!Array.isArray(metadata.intakeDraft)
) {
intakeDraft = metadata.intakeDraft;
const draft = metadata.intakeDraft as Record<string, unknown>;
intakeTitle =
typeof draft.title === "string" ? draft.title : intakeTitle;
intakeAnswer =
typeof draft.answer === "string" ? draft.answer : intakeAnswer;
}
if (metadata.publicationEligible === false) {
publicationEligible = false;
}
} catch {
// Invalid historical metadata is ignored by this read-only projection.
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)app/admin/(api/web-search-candidates/route\.ts|AdminDashboard\.tsx)$|CandidateSnapshot|draftSnapshot|intakeDraft|normalizeReviewedCandidateSnapshot' || true

echo
echo "== route ts outline =="
ast-grep outline app/admin/api/web-search-candidates/route.ts --view expanded || true

echo
echo "== AdminDashboard outline relevant =="
ast-grep outline app/admin/AdminDashboard.tsx --view expanded || true

echo
echo "== relevant snippets =="
sed -n '1,180p' app/admin/api/web-search-candidates/route.ts
echo
sed -n '280,330p' app/admin/AdminDashboard.tsx

Repository: tomnyson/tuyen_truyen_an_toan_giao_thong

Length of output: 8637


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== occurrences =="
rg -n "draftSnapshot|intakeDraft|normalizeReviewedCandidateSnapshot|CandidateSnapshot|tags\.join|citations\.map|startEdit" app/admin -S || true

echo
echo "== focused read with line numbers =="
echo "-- web-search-candidates route 1-180 --"
cat -n -v app/admin/api/web-search-candidates/route.ts | sed -n '1,180p'

echo
echo "-- AdminDashboard 1,280-340 --"
cat -n -v app/admin/AdminDashboard.tsx | sed -n '1,40p;260,350p'

Repository: tomnyson/tuyen_truyen_an_toan_giao_thong

Length of output: 16682


Validate intakeDraft before returning it as draftSnapshot.

metadata.intakeDraft is assigned after only checking that it is a non-array object, then exposed as CandidateSnapshot. Malformed drafts can miss citations/tags or contain non-array fields, and startEdit later uses the draft directly in the editor render path. Validate against the snapshot shape before assigning it to intakeDraft; otherwise keep draftSnapshot as null.

📍 Affects 2 files
  • app/admin/api/web-search-candidates/route.ts#L87-L116 (this comment)
  • app/admin/AdminDashboard.tsx#L309-L309
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/admin/api/web-search-candidates/route.ts` around lines 87 - 116, The
intakeDraft handling in the event-processing loop must validate
metadata.intakeDraft against the CandidateSnapshot draft shape, including
required citations and tags arrays and their expected field types, before
assigning it to intakeDraft or deriving intakeTitle and intakeAnswer. Leave
intakeDraft null for malformed drafts; the existing startEdit usage in
app/admin/AdminDashboard.tsx at line 309 requires no direct change.

Comment on lines +188 to +214
const workflow = await listWebSearchCandidates();
const publicationBlocked = workflow.events.some((event) => {
if (
event.candidate_id !== candidateId ||
typeof event.metadata_json !== "string"
) {
return false;
}
try {
const metadata = JSON.parse(event.metadata_json) as Record<
string,
unknown
>;
return metadata.publicationEligible === false;
} catch {
return false;
}
});
if (publicationBlocked) {
return noStoreJson(
{
error:
"Đây là hướng dẫn an toàn cho MVP, không được đưa vào kho căn cứ pháp lý.",
},
409,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg 'app/admin/api/web-search-candidates/route\.ts|PRODUCT_REQUIREMENTS\.md|web_search_candidate_events|web_search_candidates' || true

echo "== route outline/size =="
wc -l app/admin/api/web-search-candidates/route.ts 2>/dev/null || true
ast-grep outline app/admin/api/web-search-candidates/route.ts 2>/dev/null | head -120 || true

echo "== relevant route lines =="
sed -n '1,260p' app/admin/api/web-search-candidates/route.ts

echo "== search listWebSearchCandidates == "
rg -n "function listWebSearchCandidates|const listWebSearchCandidates|listWebSearchCandidates|web_search_candidate_events|publicationEligible" -S .

Repository: tomnyson/tuyen_truyen_an_toan_giao_thong

Length of output: 17170


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== lib/web-search-candidates.ts relevant section =="
sed -n '630,720p' lib/web-search-candidates.ts

echo "== tests/listWebSearchCandidates section =="
sed -n '310,350p' tests/web-search-candidates.test.mjs

echo "== docs/product requirements relevant section =="
sed -n '770,810p' docs/PRODUCT_REQUIREMENTS.md
sed -n '2355,2390p' docs/TECHNICAL_SPEC.md

echo "== targeted query evidence from implementation =="
rg -n "publicationEligible|web_search_candidate_events|LIST|listWebSearchCandidates|migrations|schema" db lib tests docs -S

Repository: tomnyson/tuyen_truyen_an_toan_giao_thong

Length of output: 26106


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== behavioral probe: bounded query misses candidate outside top-200 =="
python3 - <<'PY'
from dataclasses import dataclass
from typing import Any

`@dataclass`
class Candidate:
    id: str
    updated_at: int
    lifecycle_status: str = "draft"

`@dataclass`
class Event:
    candidate_id: str
    metadata_json: str

def list_web_search_candidates(candidates: list[Candidate], events: list[Event]):
    # Mirrors lib/web-search-candidates.ts: top-200 candidates and candidate_id IN that set.
    top = sorted(candidates, key=lambda c: (-c.updated_at, c.id))[:200]
    top_ids = {c.id for c in top}
    return [
        {"candidates": top},
        {"sources": []},
        {"revisions": []},
        {
            "events": sorted(
                [e for e in events if e.candidate_id in top_ids],
                key=lambda e: (
                    e.candidate_id,
                    -len(e.metadata_json),  # placeholder for occurred_at if metadata was JSON
                    e.candidate_id
                )
            )
        },
    ]

def publication_blocked(listed, candidate_id: str):
    # Mirrors app/admin/api/web-search-candidates/route.ts PATCH check.
    for event in listed[3]["events"]:
        if event.candidate_id != candidate_id:
            continue
        try:
            metadata = __import__("json").loads(event.metadata_json)
            if metadata.get("publicationEligible") is False:
                return True
        except Exception:
            return False
    return False

candidates: list[Candidate] = [
    Candidate("older", 1),
    Candidate("target", 0),
]
events: list[Event] = [
    Event("target", '{"publicationEligible": false}''),
]
listed = list_web_search_candidates(candidates, events)
print("listed_candidate_count=" + str(len(listed[0]["candidates"])))
print("listed_events_count=" + str(len(listed[3]["events"])))
print("publication_blocked=" + str(publication_blocked(listed, "target")))
PY

echo "== schema table for web_search_candidates =="
sed -n '760,810p' db/schema.ts

Repository: tomnyson/tuyen_truyen_an_toan_giao_thong

Length of output: 435


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== behavioral probe: bounded query misses target candidate outside top-200 =="
python3 - <<'PY'
import json

candidates = [
    ["older", "2026-08-01T00:00:00Z"],
    ["target", "2025-01-01T00:00:00Z"],
]
events = [
    ["target", json.dumps({"publicationEligible": False})],
]

top = sorted(candidates, key=lambda c: (- (c[1] > "1970-01-01"), c[0]))[:200]
top_ids = {c[0] for c in top}
listed_events = [e for e in events if e[0] in top_ids]
publication_blocked = any(
    e[0] == "target" and json.loads(e[1])["publicationEligible"] is False
    for e in listed_events
)

print(f"listed_candidates={len(top)}")
print(f"listed_events_for_target={1 if 'target' in top_ids else 0}")
print(f"metadata_is_visibility_flag_on_target_event={'publicationEligible' in json.loads(next(e[1] for e in events if e[0] == 'target'))}")
print(f"publication_blocked_from_list_when_target_outside_top_200={publication_blocked}")
PY

echo "== schema table for web_search_candidates =="
sed -n '760,810p' db/schema.ts

Repository: tomnyson/tuyen_truyen_an_toan_giao_thong

Length of output: 2208


Guard every target candidate before allowing admin mutations.

PATCH uses listWebSearchCandidates() whose events rowset only contains candidates in the top-200 list, while web_search_candidate_events stores all workflow events. If candidateId is outside that window, the eligibility scan finds no event and proceeds to save_revision/workflow transition even when metadata contains publicationEligible: false, violating DEC-015. Use a targeted query for candidate_id = ? before allowing the mutation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/admin/api/web-search-candidates/route.ts` around lines 188 - 214, The
PATCH mutation guard currently scans only the top-200 events from
listWebSearchCandidates, allowing out-of-window ineligible candidates through.
Replace the eligibility lookup around publicationBlocked with a targeted query
against web_search_candidate_events filtered by candidate_id, and preserve the
metadata parsing and 409 response when publicationEligible is false before
save_revision or workflow transitions.

Comment on lines +26 to +29
Thông điệp phạm vi MVP dành cho người dùng:
“Tra cứu nhanh các quy định gần gũi với trường học — từ giao thông, mạng xã hội
đến bản quyền”.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the MVP scope message wording with the renamed topics.

Line 26-29 says Thông điệp phạm vi MVP dành cho người dùng: "Tra cứu nhanh các quy định gần gũi với trường học — từ giao thông, mạng xã hội đến bản quyền".

This uses "mạng xã hội" and "bản quyền," the older topic terms. Elsewhere in the same PR, Section 6 renames these topics: An toàn và ứng xử trên mạng. and Bản quyền học đường. FR-04 uses the same renamed terms, and the chat-panel copy in app/page.tsx (also updated in this PR) says "ứng xử trên mạng" and "bản quyền học đường." Update Line 26-29 to use the same renamed topic terms so the product scope message stays consistent across the document and the UI.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/PRODUCT_REQUIREMENTS.md` around lines 26 - 29, Update the MVP scope
message in PRODUCT_REQUIREMENTS.md to replace the outdated “mạng xã hội” and
“bản quyền” terms with the renamed topics “An toàn và ứng xử trên mạng” and “Bản
quyền học đường,” matching Section 6, FR-04, and the chat-panel wording.

Comment on lines +49 to +78
const legacyContentHashes = new Map([
[
"a9d6f3e2-7331-4cb0-8db5-0d745b06c001",
"e14003a28151b343a78f2febf5c44773f3098818fdcf78b78dfd4acacb036a46",
],
[
"a9d6f3e2-7331-4cb0-8db5-0d745b06c002",
"f83116bbc92565c783138c91e787dc9395690b8d22e49f53d4253321da641a15",
],
[
"a9d6f3e2-7331-4cb0-8db5-0d745b06c003",
"24aedfabe5f144be50f4c93d0e1f5d779cb24962bf35b143ebb6ed37539066e6",
],
[
"a9d6f3e2-7331-4cb0-8db5-0d745b06c004",
"e86c4347d4b213247bf52a9e2a16c929c26c076fe300f81b885b5243f66b005d",
],
[
"a9d6f3e2-7331-4cb0-8db5-0d745b06c005",
"748358b54a5449ffa4e7073875923d5db339ee2bf12a157ef13a679946ec3632",
],
[
"a9d6f3e2-7331-4cb0-8db5-0d745b06c006",
"00556f00cd3631d1cb909338736822c6edf4570dab2abb86f962f903f370efff",
],
[
"a9d6f3e2-7331-4cb0-8db5-0d745b06c007",
"1569e7871065269708d54e215586e3f74c2bb224eda38d3cb27a8bf1401f7674",
],
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
fd -t f 'official-corpus-drafts.v1.json' -x node -e '
const fs = require("node:fs");
const packet = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const ids = packet.records.map((record) => record.candidateId).sort();
console.log("fixture candidateIds:", ids);
' {}
rg -n 'a9d6f3e2-7331-4cb0-8db5-0d745b06c00' lib/official-corpus-drafts.ts

Repository: tomnyson/tuyen_truyen_an_toan_giao_thong

Length of output: 826


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file outline =="
ast-grep outline lib/official-corpus-drafts.ts --view expanded || true

echo "== relevant file section =="
cat -n lib/official-corpus-drafts.ts | sed -n '1,140p'

echo "== search legacyIdentityMatches and legacyContentHashes =="
rg -n "legacyContentHashes|legacyIdentityMatches|legacyCanonical|candidateId|initial_answer_text" lib tests spec . --glob '!node_modules' --glob '!dist' --glob '!build' | head -n 200

Repository: tomnyson/tuyen_truyen_an_toan_giao_thong

Length of output: 21865


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== legacyCanonical and identity branch =="
cat -n lib/official-corpus-drafts.ts | sed -n '540,820p'

echo "== relevant official-corpus tests =="
cat -n tests/official-corpus-drafts.test.mjs | sed -n '430,535p'

echo "== fixtures version/header =="
cat -n fixtures/rag/official-corpus-drafts.v1.json | sed -n '1,50p'

Repository: tomnyson/tuyen_truyen_an_toan_giao_thong

Length of output: 18578


Document the provenance of legacyContentHashes.

The map gates legacyIdentityMatches, so wrong entries can turn an upgrade into conflict. Add a comment that names the canonical input (legacyCanonical(current.initial_answer_text, current.provider_model, existingSources.map(source => source.official_url))) and the legacy packet version/date that produced each digest.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/official-corpus-drafts.ts` around lines 49 - 78, Document the provenance
of each entry in legacyContentHashes by adding comments that identify the
canonical input legacyCanonical(current.initial_answer_text,
current.provider_model, existingSources.map(source => source.official_url)) and
the legacy packet version/date that produced its digest. Keep the existing
UUID-to-digest mappings unchanged and associate each provenance note with the
corresponding entry.

Comment on lines +833 to +849
...missingSourceIndexes.map((sourceIndex, offset) => {
const citation = record.proposedSnapshot.citations[sourceIndex];
const host = new URL(citation.url).hostname.toLowerCase();
return db.prepare(`
INSERT INTO web_search_candidate_sources (
candidate_id, display_order, title, official_url,
official_host, url_sha256
) VALUES (?, ?, ?, ?, ?, ?)
`).bind(
record.candidateId,
nextDisplayOrder + offset,
citation.title,
citation.url,
host,
sourceHashes[sourceIndex],
);
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Apply the same host guard used by the new-record path.

The new-record insert at lines 792-796 rejects a non-official host with isOfficialGovernmentHost. The binding-upgrade insert omits that guard. Both paths write to web_search_candidate_sources.official_host. Keep the invariant identical in both places.

♻️ Proposed change
           ...missingSourceIndexes.map((sourceIndex, offset) => {
             const citation = record.proposedSnapshot.citations[sourceIndex];
             const host = new URL(citation.url).hostname.toLowerCase();
+            if (!isOfficialGovernmentHost(host)) {
+              throw new Error("official corpus source host is invalid");
+            }
             return db.prepare(`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
...missingSourceIndexes.map((sourceIndex, offset) => {
const citation = record.proposedSnapshot.citations[sourceIndex];
const host = new URL(citation.url).hostname.toLowerCase();
return db.prepare(`
INSERT INTO web_search_candidate_sources (
candidate_id, display_order, title, official_url,
official_host, url_sha256
) VALUES (?, ?, ?, ?, ?, ?)
`).bind(
record.candidateId,
nextDisplayOrder + offset,
citation.title,
citation.url,
host,
sourceHashes[sourceIndex],
);
}),
...missingSourceIndexes.map((sourceIndex, offset) => {
const citation = record.proposedSnapshot.citations[sourceIndex];
const host = new URL(citation.url).hostname.toLowerCase();
if (!isOfficialGovernmentHost(host)) {
throw new Error("official corpus source host is invalid");
}
return db.prepare(`
INSERT INTO web_search_candidate_sources (
candidate_id, display_order, title, official_url,
official_host, url_sha256
) VALUES (?, ?, ?, ?, ?, ?)
`).bind(
record.candidateId,
nextDisplayOrder + offset,
citation.title,
citation.url,
host,
sourceHashes[sourceIndex],
);
}),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/official-corpus-drafts.ts` around lines 833 - 849, Update the
binding-upgrade insert within the missingSourceIndexes mapping to apply the same
isOfficialGovernmentHost validation used by the new-record path before writing
web_search_candidate_sources.official_host. Preserve the existing insert
behavior for official hosts and reject or handle non-official hosts consistently
with the new-record flow.

Comment on lines +78 to +93
export function reviewedCandidateMatchesChatTopic(
snapshot: ReviewedCandidateSnapshot,
topic: ChatTopic,
) {
if (snapshot.topic !== chatTopicLabel(topic)) return false;
if (topic !== "copyright") return true;
return snapshot.tags
.map(normalizeVietnamese)
.some(
(tag) =>
tag.includes("ban quyen") ||
tag.includes("quyen tac gia") ||
tag.includes("copyright"),
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
fd -a 'web-search-candidates\.ts$' . || true

echo "== outline =="
ast-grep outline lib/web-search-candidates.ts --view expanded || true

echo "== relevant sections =="
sed -n '1,130p' lib/web-search-candidates.ts
sed -n '720,790p' lib/web-search-candidates.ts
sed -n '800,830p' lib/web-search-candidates.ts

echo "== usages of subtypeSql/topicSql/reviewedCandidateMatchesChatTopic =="
rg -n "subtypeSql|topicSql|reviewedCandidateMatchesChatTopic|findReviewedWebCandidate|normalizeVietnamese|chatTopicLabel" lib/web-search-candidates.ts

Repository: tomnyson/tuyen_truyen_an_toan_giao_thong

Length of output: 9912


🌐 Web query:

SQLite SQL date time functions lower documentation does it change accents UTF8

💡 Result:

By default, the SQLite lower and upper functions only perform case conversion on 7-bit ASCII characters [1][2]. They do not change, recognize, or handle accents or any other non-ASCII Unicode characters [3][4]. If your data includes accented characters, the standard built-in lower and upper functions will leave them unchanged [2]. To enable full Unicode-aware case conversion, you must compile SQLite with the International Components for Unicode (ICU) extension enabled (-DSQLITE_ENABLE_ICU) [3][4]. Once this extension is loaded, the lower and upper functions gain the ability to perform locale-specific case mapping across the full Unicode range [3]. The design choice to limit these functions to ASCII by default exists to ensure stability and compatibility, as Unicode case-folding rules can change over time and the necessary tables for full support are significantly larger than the entire SQLite library itself [5][4].

Citations:


🌐 Web query:

SQLite UTF8 lower function does not change letter case non lowercase letters

💡 Result:

The SQLite built-in lower() function is designed to convert characters to lowercase only for the 26 letters of the basic ASCII alphabet (A-Z) [1][2][3]. Consequently, it will not change the case of any non-ASCII characters, including accented letters, diacritics, or other Unicode characters that exist outside the ASCII range [4][5][6]. To perform case conversions or case-insensitive comparisons for the full range of Unicode characters, you must use an extension or custom implementation: 1. ICU Extension: The official SQLite ICU (International Components for Unicode) extension provides Unicode-aware versions of the lower() and upper() functions, as well as an enhanced LIKE operator that handles full Unicode case folding [2][7][8]. 2. Custom Collation or Functions: For applications requiring Unicode-aware case-insensitive behavior without the ICU extension, developers often register custom collation sequences or user-defined functions that handle Unicode case folding via host language libraries (e.g., Python's locale or casefold methods) [9][8][10]. 3. FTS5 Extension: The FTS5 full-text search extension includes a unicode61 tokenizer that performs Unicode folding, which can be leveraged for advanced text search requirements [11]. In summary, the default behavior you are observing is intentional; SQLite is optimized to be lightweight, and full Unicode case mapping is delegated to external extensions or application-level logic [5][8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== legal-content outline =="
ast-grep outline lib/legal-content.ts --view expanded || true

echo "== normalizeVietnamese implementation =="
sed -n '1,230p' lib/legal-content.ts

echo "== chat topic implementation =="
fd -a 'chat-topic-scope\.ts$' . | while read -r f; do
  echo "--- $f"
  ast-grep outline "$f" --view expanded || true
  sed -n '1,220p' "$f"
done

Repository: tomnyson/tuyen_truyen_an_toan_giao_thong

Length of output: 11407


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== behavioral SQL probe with default sqlite3 if available =="
if command -v sqlite3 >/dev/null 2>&1; then
  for value in "Bản quyền học đường" "BẢN QUYỀN học đường" "bản quyền" "bÀN qUYềN"; do
    printf '%s -> ' "$value"
    sqlite3 :memory: "SELECT lower('$value');" | cat -A
  done
else
  echo "sqlite3 not available"
fi

echo "== JS normalization behavior =="
node - <<'JS'
const src = require('fs').readFileSync('lib/legal-content.ts','utf8');
console.log('contains unicodeNormalize/unaccent:', /unicodeNormalize|unaccent/i.test(src));
JS

echo "== import graph =="
printf '"lib/legal-content.ts"' > /tmp/roots.txt
python3 - <<'PY'
from pathlib import Path

roots = []
try:
    with open('/tmp/roots.txt') as f:
        roots = [p.strip() for p in f if p.strip()]
except FileNotFoundError:
    pass

done = set()
for root in roots:
    q = [root]
    while q:
        cur = q.pop(0)
        if cur in done:
            continue
        done.add(cur)
        text = Path(cur).read_text() if Path(cur).exists() else ''
        for line in text.splitlines():
            if 'from "' in line or "from '" in line:
                _, _, rest = line.partition("from ")
                mod = rest.strip().strip("'\"');
                if '/' in mod:
                    path = str(Path(cur).parent / (mod + '.ts'))
                else:
                    continue
                if path not in done:
                    q.append(path)
                    print(f"{mod} -> {path}")
PY

Repository: tomnyson/tuyen_truyen_an_toan_giao_thong

Length of output: 502


Use the same Vietnamese normalization path for SQL copyright tag matching.

reviewedCandidateMatchesChatTopic strips Vietnamese diacritics and lowercases tags before matching ban quyen/quyen tac gia/copyright, but subtypeSql uses SQLite’s built-in lower() to match NFD-accented strings. SQLite default lower() is ASCII-only, so copyright tags such as BẢN QUYỀN or other case/diacritic variants can be filtered out before the JS-level check runs. Replace subtypeSql with a matching diacritic-stripped expression or remove it and rely on the JS check. Add a positive findReviewedWebCandidate test for a stored copyright-tagged candidate to catch SQL/JS matching regression.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/web-search-candidates.ts` around lines 78 - 93, Update subtypeSql used by
findReviewedWebCandidate to apply the same Vietnamese diacritic-stripping and
case-normalization as reviewedCandidateMatchesChatTopic, or remove the SQL tag
filter so the JS check handles matching consistently. Add a positive
findReviewedWebCandidate test with a stored candidate tagged using a copyright
variant such as BẢN QUYỀN, verifying it is returned.

Comment thread package.json
"smoke:ai": "node --env-file=.env.local --experimental-strip-types scripts/smoke-openai-evidence.mjs",
"smoke:web-search": "node --env-file=.env.local --experimental-strip-types scripts/smoke-openai-web-search.mjs",
"shadow:ai": "node --env-file=.env.local --experimental-strip-types scripts/shadow-openai-evidence.mjs",
"corpus:import:local": "node --experimental-strip-types scripts/import-official-corpus-drafts.mjs",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
fd -H -t f -d 2 '^(package.json|.nvmrc|.node-version|.tool-versions)$' -x cat -n {}
fd -H -t f -e yml -e yaml . .github -x rg -n 'node-version|setup-node' {} || true
rg -n 'experimental-strip-types|node:sqlite|registerHooks' --glob '!**/node_modules/**'

Repository: tomnyson/tuyen_truyen_an_toan_giao_thong

Length of output: 3495


🌐 Web query:

Node.js module.registerHooks 22.15.0 release notes

💡 Result:

In Node.js 22.15.0, which was released on April 23, 2025, the module.registerHooks method was implemented [1][2]. This API allows users to register synchronous module customization hooks (specifically 'resolve' and 'load') that run directly on the same thread where modules are loaded [3][4]. This implementation was a significant addition designed to address several limitations of the existing module.register API, such as overhead from inter-thread communication and challenges with CommonJS module customization [5][3]. By running synchronously on the main thread, module.registerHooks is easier to debug and avoids many of the deadlock issues associated with the asynchronous, off-thread hooks used by module.register [3][4]. Since its introduction in v22.15.0, module.registerHooks has become the recommended approach for customizing module resolution and loading [5]. Consequently, the older module.register method has been marked as deprecated in favor of this synchronous alternative [5][6].

Citations:


🌐 Web query:

Node.js node:sqlite available 22.5.0 release notes

💡 Result:

The node:sqlite module was introduced in Node.js version 22.5.0 [1][2]. Key details regarding its release include: - Purpose: The module provides built-in support for SQLite databases [3][4]. - Status at release: It was introduced as an experimental feature [4] with a stability rating of 1.1 (Active development) [3]. - Accessibility: The module is accessed via the node: scheme (e.g., import sqlite from 'node:sqlite' or require('node:sqlite')) [3]. It requires the --experimental-sqlite flag to be enabled to function [4]. - Functionality: The release included the DatabaseSync class for synchronous database operations, as well as the StatementSync class for managing SQL statements [3]. The addition of this module was documented in the Node.js 22.5.0 release notes [1][2], which cite the primary pull request (PR #53752) that implemented the feature [4].

Citations:


🌐 Web query:

Node.js experimental-strip-types flag experimental 22

💡 Result:

The --experimental-strip-types flag was a feature introduced in Node.js v22 to allow the runtime to execute TypeScript files natively by stripping out type annotations [1][2][3]. Key details regarding its evolution and current status in Node.js: Functionality: The feature performs "type stripping," where it removes TypeScript type annotations and replaces them with whitespace to produce valid JavaScript [4][2]. It does not perform type checking [4][3]. Because it simply removes types, it does not support TypeScript features that require code transformation (such as enums, namespaces, or parameter properties) unless the additional --experimental-transform-types flag is used [2][3]. Additionally, Node.js does not read tsconfig.json files, meaning features dependent on that configuration (like path mapping or down-level compilation) are unsupported [4][5]. Evolution in Node.js 22: - Initial Introduction: The feature was introduced as experimental [6][2]. - Status Change: Starting in Node.js v22.18.0, the type stripping feature no longer emits an experimental warning and is enabled by default [4][2]. - Current Usage: Because it is enabled by default, you do not need to use the --experimental-strip-types flag in current versions of Node.js 22 (v22.18.0 and later) [4][2]. If you wish to disable the feature, you can use the --no-experimental-strip-types flag [1][7]. Later Versions: In later versions of Node.js (such as v24+ and v25+), type stripping is considered stable, and the functionality is now standard [4][5].

Citations:


Update the Node.js floor for corpus:import:local.

package.json currently declares node >=22.13.0, but scripts/import-official-corpus-drafts.mjs uses module.registerHooks(), which is only available from Node.js 22.15.0. This will fail on the declared floor with an unclear error; raise the engines.node minimum to at least 22.15.0.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 28, Raise the package.json engines.node minimum from
22.13.0 to at least 22.15.0 so the corpus:import:local script using
module.registerHooks() runs on every supported Node.js version.

Comment on lines +24 to +61
class PreparedStatement {
constructor(database, sql, values = []) {
this.database = database;
this.sql = sql;
this.values = values;
}

bind(...values) {
return new PreparedStatement(this.database, this.sql, values);
}

execute() {
const results = this.database.prepare(this.sql).all(...this.values);
return { success: true, results };
}
}

class LocalD1Adapter {
constructor(database) {
this.database = database;
}

prepare(sql) {
return new PreparedStatement(this.database, sql);
}

async batch(statements) {
this.database.exec("BEGIN IMMEDIATE");
try {
const results = statements.map((statement) => statement.execute());
this.database.exec("COMMIT");
return results;
} catch (error) {
this.database.exec("ROLLBACK");
throw error;
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Extract the local D1 adapter into one shared helper. Both files implement the same prepare/bind/execute wrapper and the same BEGIN IMMEDIATE batch adapter with different class names. The shared root cause is a missing shared module for local D1 emulation, so both copies must track the D1Like contract and the success flag independently.

  • scripts/import-official-corpus-drafts.mjs#L24-L61: move PreparedStatement and LocalD1Adapter into a shared module, for example lib/local-d1-adapter.mjs, and import them here. Move the duplicated registerHooks resolve hook at lines 7-18 into the same module.
  • tests/official-corpus-drafts.test.mjs#L55-L94: delete Prepared and D1Adapter and import the shared adapter instead, so the test exercises the same emulation as the script.
📍 Affects 2 files
  • scripts/import-official-corpus-drafts.mjs#L24-L61 (this comment)
  • tests/official-corpus-drafts.test.mjs#L55-L94
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/import-official-corpus-drafts.mjs` around lines 24 - 61, Extract
PreparedStatement, LocalD1Adapter, and the duplicated registerHooks resolve hook
into a shared module such as lib/local-d1-adapter.mjs; update
scripts/import-official-corpus-drafts.mjs lines 24-61 to import and use it. In
tests/official-corpus-drafts.test.mjs lines 55-94, delete the local Prepared and
D1Adapter implementations and import the shared adapter so both consumers
independently preserve the D1Like contract and success flag.

Comment on lines +245 to +283
test("official-looking output with the wrong source kind is never persisted", async () => {
let persisted = 0;
Object.assign(globalThis.__topicScopeWorkerEnv, {
AI_WEB_SEARCH_ENABLED: "true",
OPENAI_API_KEY: "test-key",
OPENAI_MODEL: "gpt-5.4-mini",
});
const chat = createChatHandler({
limiter: () => ({ consumeChat: async () => allowed }),
telemetry: { emit() {} },
managedAnswer: async () => null,
curatedAnswer: () => null,
reviewedWebAnswer: async () => null,
reserveWebBudget: async () => ({ dayStart: 1, reservedTokens: 12_000 }),
settleWebBudget: async () => true,
persistWebCandidate: async () => {
persisted += 1;
return "33333333-3333-4333-8333-333333333333";
},
webSearch: async () => ({
ok: true,
sourceKind: "reference",
answer: "Kết luận: Nội dung sai trust tier.",
sections: [],
warning: "Cảnh báo",
sources: [{ title: "Nguồn", url: "https://vbpl.vn/document" }],
model: "gpt-5.4-mini",
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
}),
});

const body = await (await chat(request("Quy định giao thông mới?"))).json();
assert.equal(body.mode, "unavailable");
assert.equal(body.answer, IN_SCOPE_NO_MATCH_ANSWER);
assert.equal(persisted, 0);
for (const key of Object.keys(globalThis.__topicScopeWorkerEnv)) {
delete globalThis.__topicScopeWorkerEnv[key];
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Delete the injected worker env in a failure-safe hook.

Each provider test assigns keys to globalThis.__topicScopeWorkerEnv and deletes them after the assertions. If an assertion throws, the delete loop never runs. AI_WEB_SEARCH_ENABLED=true then leaks into the following tests, which run in the same process, and one real failure cascades into unrelated failures. The same pattern repeats at Lines 285-325, 327-371, 373-418, 420-487, 489-535 and 537-621.

Use the test context cleanup hook so the reset always runs.

♻️ Proposed refactor for one site; apply the same change to every provider test
-test("official-looking output with the wrong source kind is never persisted", async () => {
+test("official-looking output with the wrong source kind is never persisted", async (t) => {
   let persisted = 0;
+  t.after(() => {
+    for (const key of Object.keys(globalThis.__topicScopeWorkerEnv)) {
+      delete globalThis.__topicScopeWorkerEnv[key];
+    }
+  });
   Object.assign(globalThis.__topicScopeWorkerEnv, {
     AI_WEB_SEARCH_ENABLED: "true",
     OPENAI_API_KEY: "test-key",
     OPENAI_MODEL: "gpt-5.4-mini",
   });

Then remove the trailing delete loop from each test body.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("official-looking output with the wrong source kind is never persisted", async () => {
let persisted = 0;
Object.assign(globalThis.__topicScopeWorkerEnv, {
AI_WEB_SEARCH_ENABLED: "true",
OPENAI_API_KEY: "test-key",
OPENAI_MODEL: "gpt-5.4-mini",
});
const chat = createChatHandler({
limiter: () => ({ consumeChat: async () => allowed }),
telemetry: { emit() {} },
managedAnswer: async () => null,
curatedAnswer: () => null,
reviewedWebAnswer: async () => null,
reserveWebBudget: async () => ({ dayStart: 1, reservedTokens: 12_000 }),
settleWebBudget: async () => true,
persistWebCandidate: async () => {
persisted += 1;
return "33333333-3333-4333-8333-333333333333";
},
webSearch: async () => ({
ok: true,
sourceKind: "reference",
answer: "Kết luận: Nội dung sai trust tier.",
sections: [],
warning: "Cảnh báo",
sources: [{ title: "Nguồn", url: "https://vbpl.vn/document" }],
model: "gpt-5.4-mini",
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
}),
});
const body = await (await chat(request("Quy định giao thông mới?"))).json();
assert.equal(body.mode, "unavailable");
assert.equal(body.answer, IN_SCOPE_NO_MATCH_ANSWER);
assert.equal(persisted, 0);
for (const key of Object.keys(globalThis.__topicScopeWorkerEnv)) {
delete globalThis.__topicScopeWorkerEnv[key];
}
});
test("official-looking output with the wrong source kind is never persisted", async (t) => {
let persisted = 0;
t.after(() => {
for (const key of Object.keys(globalThis.__topicScopeWorkerEnv)) {
delete globalThis.__topicScopeWorkerEnv[key];
}
});
Object.assign(globalThis.__topicScopeWorkerEnv, {
AI_WEB_SEARCH_ENABLED: "true",
OPENAI_API_KEY: "test-key",
OPENAI_MODEL: "gpt-5.4-mini",
});
const chat = createChatHandler({
limiter: () => ({ consumeChat: async () => allowed }),
telemetry: { emit() {} },
managedAnswer: async () => null,
curatedAnswer: () => null,
reviewedWebAnswer: async () => null,
reserveWebBudget: async () => ({ dayStart: 1, reservedTokens: 12_000 }),
settleWebBudget: async () => true,
persistWebCandidate: async () => {
persisted += 1;
return "33333333-3333-4333-8333-333333333333";
},
webSearch: async () => ({
ok: true,
sourceKind: "reference",
answer: "Kết luận: Nội dung sai trust tier.",
sections: [],
warning: "Cảnh báo",
sources: [{ title: "Nguồn", url: "https://vbpl.vn/document" }],
model: "gpt-5.4-mini",
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
}),
});
const body = await (await chat(request("Quy định giao thông mới?"))).json();
assert.equal(body.mode, "unavailable");
assert.equal(body.answer, IN_SCOPE_NO_MATCH_ANSWER);
assert.equal(persisted, 0);
for (const key of Object.keys(globalThis.__topicScopeWorkerEnv)) {
delete globalThis.__topicScopeWorkerEnv[key];
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/chat-topic-scope.test.mjs` around lines 245 - 283, Update every
provider test identified in this file, including the test containing
`official-looking output with the wrong source kind is never persisted`, to
register worker-environment cleanup through the test context’s failure-safe
cleanup hook. Move each `globalThis.__topicScopeWorkerEnv` key deletion into
that hook and remove the corresponding trailing delete loop from the test body,
preserving the existing test setup and assertions.

@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tuyen-truyen-an-toan-giao-thong Ready Ready Preview Aug 1, 2026 7:36am

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
app/api/chat/route.ts (1)

487-523: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Enforce the unverified label for every reference result.

The route forwards referenceResult.warning without enforcing chưa kiểm chứng/chỉ tham khảo. An unreviewed fine can therefore reach the client with arbitrary provider text.

  • app/api/chat/route.ts#L487-L523: return a server-defined warning containing chưa kiểm chứng/chỉ tham khảo, or reject warnings that do not contain that label.
  • tests/chat-topic-scope.test.mjs#L453-L481: assert that the returned warning contains the required label.
Proposed fix
+const referenceWarning =
+  "Chưa kiểm chứng/chỉ tham khảo: Nội dung từ nguồn tham khảo ngoài và cần được đối chiếu với văn bản chính thức.";
+
 ...
- warning: referenceResult.warning,
+ warning: referenceWarning,

As per coding guidelines, unreviewed web-search output must be prominently labelled chưa kiểm chứng/chỉ tham khảo.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/chat/route.ts` around lines 487 - 523, Ensure the reference-result
response in app/api/chat/route.ts lines 487-523 always exposes a
server-controlled warning containing “chưa kiểm chứng/chỉ tham khảo”, rather
than forwarding arbitrary referenceResult.warning; reject or replace warnings
that lack the required label. Update the corresponding assertion in
tests/chat-topic-scope.test.mjs lines 453-481 to verify the returned warning
contains that label.

Source: Coding guidelines

app/page.tsx (2)

560-560: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the exact supported copyright scope.

The placeholder ends with bản quyền…, but the documented supported topic is bản quyền học đường. The current wording can invite questions outside the supported school-copyright scope.

-                placeholder="Hỏi về giao thông, an toàn/ứng xử trên mạng hoặc bản quyền…"
+                placeholder="Hỏi về giao thông, an toàn/ứng xử trên mạng hoặc bản quyền học đường…"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/page.tsx` at line 560, Update the placeholder in the page component to
use the exact supported topic “bản quyền học đường” instead of the broader “bản
quyền”, while preserving the existing wording and punctuation for the other
topics.

127-155: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve every published citation.

PublishedContent.laws[].citations is an array, but this mapping selects only item.citations?.[0] and stores one LawItem.citation. The modal then exposes only one official link. Records with multiple approved citations lose source traceability, contrary to docs/PRODUCT_REQUIREMENTS.md Lines 213–224.

Keep the citation array through LawItem and render one source link per citation. Add a regression with two citations.

Suggested projection change
-          const firstCitation = item.citations?.[0];
+          const citations = item.citations ?? [];
...
-            citation: firstCitation ? {
-              ...
-            } : undefined,
+            citations: citations.map((citation) => ({
+              ...
+            })),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/page.tsx` around lines 127 - 155, Update the law projection in the
managed-laws mapping to preserve the full item.citations array instead of
selecting firstCitation, extending the LawItem shape and related consumers as
needed. Update the modal rendering to expose one official source link for every
citation while retaining the existing citation field mappings and empty-array
behavior. Add a regression test covering a law with two citations and verifying
both source links are rendered.
docs/PROGRESS.md (1)

24-29: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Recalculate the overview totals.

The story table at Lines 35–64 contains 30 rows: 10 Done and 20 Partial. The overview reports 10 Done and 18 Partial. Unless two stories are intentionally excluded and documented, the progress summary is inaccurate.

As per coding guidelines, status completion requires verification evidence and progress records must reflect the implementation state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/PROGRESS.md` around lines 24 - 29, Update the overview total row in
PROGRESS.md to reflect all 30 story rows: 10 Done and 20 Partial, unless the two
excluded stories are explicitly documented. Keep the category totals consistent
with the corrected summary and the implementation evidence requirements.

Source: Coding guidelines

lib/openai-web-search.ts (1)

222-226: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fix blank OPENAI_WEB_SEARCH_MODEL fallback handling.

An empty override is selected instead of falling back to OPENAI_MODEL. The example configuration therefore causes direct web search to fail with INVALID_CONFIG.

  • lib/openai-web-search.ts#L222-L226: select OPENAI_WEB_SEARCH_MODEL only when it is non-empty, then fall back to OPENAI_MODEL.
  • .env.example#L32-L35: retain the blank override example only after the parser implements that fallback.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/openai-web-search.ts` around lines 222 - 226, Update the model selection
logic near the OpenAI web-search configuration in lib/openai-web-search.ts lines
222-226 to use OPENAI_WEB_SEARCH_MODEL only when it is non-empty, otherwise fall
back to OPENAI_MODEL. Retain the blank override example in .env.example lines
32-35 after this fallback behavior is implemented.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/PRODUCT_REQUIREMENTS.md`:
- Around line 609-615: Add the missing DEC-013 entry to the decision log in
PRODUCT_REQUIREMENTS.md, using the canonical topic-gate decision recorded in
TECHNICAL_SPEC.md or PROGRESS.md, or link to that canonical entry. Preserve the
existing chronological decision-log structure and ensure the entry remains
traceable to the relevant user story and technical specification.

In `@docs/TECHNICAL_SPEC.md`:
- Around line 1172-1177: Update the canonical /api/chat response contract near
the earlier response-contract section to include the web_search mode,
sourceKind, warnings, and structured sections consumed by app/page.tsx, or
explicitly mark that older knowledge/unavailable contract as legacy. Ensure the
documentation no longer presents contradictory response modes.
- Around line 2379-2386: Update the earlier reference-output rules around the
policy section identified by the quantitative restrictions so they no longer
require general guidance, reject provider-supplied amounts/document
details/provisions/dates, or replace that prose with a safe fallback; align them
with DEC-017, or explicitly mark them as superseded by DEC-017, leaving one
normative policy.

---

Outside diff comments:
In `@app/api/chat/route.ts`:
- Around line 487-523: Ensure the reference-result response in
app/api/chat/route.ts lines 487-523 always exposes a server-controlled warning
containing “chưa kiểm chứng/chỉ tham khảo”, rather than forwarding arbitrary
referenceResult.warning; reject or replace warnings that lack the required
label. Update the corresponding assertion in tests/chat-topic-scope.test.mjs
lines 453-481 to verify the returned warning contains that label.

In `@app/page.tsx`:
- Line 560: Update the placeholder in the page component to use the exact
supported topic “bản quyền học đường” instead of the broader “bản quyền”, while
preserving the existing wording and punctuation for the other topics.
- Around line 127-155: Update the law projection in the managed-laws mapping to
preserve the full item.citations array instead of selecting firstCitation,
extending the LawItem shape and related consumers as needed. Update the modal
rendering to expose one official source link for every citation while retaining
the existing citation field mappings and empty-array behavior. Add a regression
test covering a law with two citations and verifying both source links are
rendered.

In `@docs/PROGRESS.md`:
- Around line 24-29: Update the overview total row in PROGRESS.md to reflect all
30 story rows: 10 Done and 20 Partial, unless the two excluded stories are
explicitly documented. Keep the category totals consistent with the corrected
summary and the implementation evidence requirements.

In `@lib/openai-web-search.ts`:
- Around line 222-226: Update the model selection logic near the OpenAI
web-search configuration in lib/openai-web-search.ts lines 222-226 to use
OPENAI_WEB_SEARCH_MODEL only when it is non-empty, otherwise fall back to
OPENAI_MODEL. Retain the blank override example in .env.example lines 32-35
after this fallback behavior is implemented.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 43214507-0336-4ecd-aa34-40f8e92b79fc

📥 Commits

Reviewing files that changed from the base of the PR and between 4143c4e and afeb93c.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (17)
  • .env.example
  • AGENTS.md
  • app/api/chat/route.ts
  • app/page.tsx
  • docs/PRODUCT_REQUIREMENTS.md
  • docs/PROGRESS.md
  • docs/TECHNICAL_SPEC.md
  • docs/USER_STORIES.md
  • lib/chat-answer-presentation.ts
  • lib/chat-topic-scope.ts
  • lib/legal-chat.ts
  • lib/openai-web-search.ts
  • lib/web-search-candidates.ts
  • package.json
  • scripts/smoke-openai-web-search.mjs
  • tests/chat-topic-scope.test.mjs
  • tests/openai-web-search.test.mjs

Comment on lines +609 to +615
| 2026-07-31 | DEC-012 | Khi official web search không có câu trả lời đủ điều kiện, cho phép thêm một lượt reference search có kiểm soát. | Reference allowlist ban đầu chỉ có `thuvienphapluat.vn`; output phải ghi rõ không chính thống/cần xác minhkhông persist/candidate/RAG. Hạn chế định lượng ban đầu được DEC-017 thay đổi. |
| 2026-07-31 | DEC-011 | Kết quả web qua official guard được lưu thành immutable draft không chứa raw question. | D1 persistence là điều kiện trước khi trả web result; chỉ stable principal + independent reviewer mới publish candidate vào reviewed retrieval. |
| 2026-07-31 | DEC-014 | Official corpus seed là review packet versioned, import idempotent vào draft-only và bind full-record hash vào audit metadata. | Không auto-publish; legal record đi tiếp qua workflow hiện có, còn canonical question không được lưu vào D1. |
| 2026-07-31 | DEC-015 | MVP được trả ngay official safety guidance không đưa ra căn cứ, tội danh hoặc mức phạt; không nới gate cho legal candidate. | Hướng dẫn tài khoản bị chiếm có source Chính phủ và form ngắn; API/CMS chặn record này đi vào legal RAG. Quy trình bốn mắt vẫn giữ cho mọi legal citation/sanction. |
| 2026-07-31 | DEC-016 | Direct web-search chấp nhận mọi `OPENAI_MODEL` có model ID định dạng an toàn thay vì exact allowlist. | Model là cấu hình server-only, không nhận từ request người dùng. Model không có Responses API/web-search capability trả lỗi an toàn; offline evidence/shadow vẫn giữ exact model policy để tái lập evaluation. |
| 2026-07-31 | DEC-017 | Chủ dự án chấp nhận hiển thị mức phạt/chi tiết pháp lý do web-search trả về dù chưa được kiểm chứng. | Bắt buộc cảnh báo nổi bật “chỉ tham khảo/chưa kiểm chứng”, source link qua exact authority guard và không gọi là reviewed evidence. Official result vẫn chỉ lưu draft; reference không persist/RAG. |
| 2026-07-31 | DEC-018 | Hosted web-search được chờ tối đa 30 giây và lỗi provider phải được phân biệt với no-match. | Cấu hình mẫu/default dùng 30 giây. Khi provider timeout/error/refusal, end user nhận thông báo dịch vụ tra cứu chưa hoàn tất và có thể thử lại; không nhận thông báo sai rằng nguồn không có dữ liệu. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add DEC-013 to this decision log.

The topic-gate decision is a material product decision. docs/TECHNICAL_SPEC.md and docs/PROGRESS.md record DEC-013, but this log jumps from DEC-012 and DEC-011 to DEC-014. Add the same decision or link the canonical entry.

As per coding guidelines, documentation-driven delivery requires every implementation change to stay traceable to a user story and a technical specification.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/PRODUCT_REQUIREMENTS.md` around lines 609 - 615, Add the missing DEC-013
entry to the decision log in PRODUCT_REQUIREMENTS.md, using the canonical
topic-gate decision recorded in TECHNICAL_SPEC.md or PROGRESS.md, or link to
that canonical entry. Preserve the existing chronological decision-log structure
and ensure the entry remains traceable to the relevant user story and technical
specification.

Source: Coding guidelines

Comment thread docs/TECHNICAL_SPEC.md
Comment on lines +1172 to +1177
3. Reference live search giữ section
`summary|details|sanctions|next_steps|limitations`, loại
`legal_basis|legal_remedies|examples`, luôn cảnh báo không chính thống/chưa
kiểm chứng và không gọi persistence. Provider answer phải tự match topic đã
phân loại trước khi hiển thị. Section `sanctions` chỉ có khi kết quả search
chứa mức phạt; không có mức phạt thì section bị bỏ hoàn toàn.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Update the canonical /api/chat mode contract.

This section documents web_search, sourceKind, warnings, and structured sections. The earlier response contract at Lines 114–124 still lists only knowledge and unavailable. app/page.tsx consumes web_search and sections.

Update the earlier contract or mark it as legacy. Otherwise API clients receive contradictory documentation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/TECHNICAL_SPEC.md` around lines 1172 - 1177, Update the canonical
/api/chat response contract near the earlier response-contract section to
include the web_search mode, sourceKind, warnings, and structured sections
consumed by app/page.tsx, or explicitly mark that older knowledge/unavailable
contract as legacy. Ensure the documentation no longer presents contradictory
response modes.

Comment thread docs/TECHNICAL_SPEC.md
Comment on lines +2379 to +2386
- **DEC-017:** Product owner chấp nhận rủi ro MVP và cho phép direct
official/reference web-search giữ số tiền, số hiệu văn bản, điều-khoản-điểm,
ngày hiệu lực và biện pháp pháp lý do provider trả về. Public response phải
có warning trước nội dung, dùng nhãn “tham khảo — chưa kiểm chứng”, chỉ render
source URL qua exact authority guard và không mô tả output là reviewed
application evidence. Official result chỉ persist immutable draft;
reference result live/no-store, không candidate/RAG. Thiếu source hợp lệ,
malformed/active content hoặc sai topic vẫn fail closed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Remove or mark the superseded quantitative restriction.

DEC-017 now permits unverified amounts, document numbers, provisions, and dates under a warning. The earlier rules at Lines 1572–1580 still require reference output to be general guidance, reject these fields, and replace the provider prose with a safe fallback.

Keep one normative policy. Update the earlier rules or mark them as superseded by DEC-017.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/TECHNICAL_SPEC.md` around lines 2379 - 2386, Update the earlier
reference-output rules around the policy section identified by the quantitative
restrictions so they no longer require general guidance, reject
provider-supplied amounts/document details/provisions/dates, or replace that
prose with a safe fallback; align them with DEC-017, or explicitly mark them as
superseded by DEC-017, leaving one normative policy.

@quangbmt114
quangbmt114 merged commit a174874 into main Aug 1, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant