Skip to content

Commit 1eee411

Browse files
author
Gemini CLI
committed
fix(std-platform): citation_insert -- correct target FK dispatch + citation_text guard + insert/verify column split
1 parent ffceaa9 commit 1eee411

2 files changed

Lines changed: 252 additions & 24 deletions

File tree

data_agent/api/standards_routes.py

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -325,43 +325,77 @@ async def citation_insert(request: Request):
325325
if not clause_id or not cand:
326326
return JSONResponse({"error": "clause_id and candidate required"},
327327
status_code=400)
328+
329+
# Fix #4: validate citation_text early
330+
citation_text = (cand.get("snippet") or "").strip()[:500]
331+
if not citation_text:
332+
return JSONResponse({"error": "citation_text is required"},
333+
status_code=400)
334+
335+
# Fix #3: dispatch target_kind to the correct FK column
328336
kind = cand.get("kind", "")
329-
target_kind_map = {
330-
"std_clause": "std_clause",
331-
"std_data_element": "std_clause", # element belongs to a clause
332-
"std_term": "std_clause",
333-
"kb_chunk": "internet_search",
334-
"web_snapshot": "web_snapshot",
335-
}
336-
target_kind = target_kind_map.get(kind)
337-
if target_kind is None:
338-
return JSONResponse(
339-
{"error": f"unsupported candidate kind: {kind}"},
340-
status_code=400)
341337
target_clause_id = None
342-
target_url = cand.get("target_url")
338+
target_data_element_id = None
339+
target_term_id = None
340+
target_document_id = None
341+
target_url = None
343342
snapshot_id = None
344-
if kind in ("std_clause",):
343+
344+
if kind == "std_clause":
345+
target_kind = "std_clause"
345346
target_clause_id = cand.get("target_id")
347+
elif kind == "std_data_element":
348+
target_kind = "std_data_element"
349+
target_data_element_id = cand.get("target_id")
350+
elif kind == "std_term":
351+
target_kind = "std_term"
352+
target_term_id = cand.get("target_id")
353+
elif kind == "std_document":
354+
target_kind = "std_document"
355+
target_document_id = cand.get("target_id")
356+
elif kind == "kb_chunk":
357+
# KB chunk has no FK target — record as internet_search with the
358+
# source URL if the candidate carried one.
359+
target_kind = "internet_search"
360+
target_url = cand.get("target_url")
346361
elif kind == "web_snapshot":
362+
target_kind = "web_snapshot"
347363
snapshot_id = cand.get("target_id")
348-
citation_text = (cand.get("snippet") or "")[:500]
364+
target_url = cand.get("target_url")
365+
elif kind == "external_url":
366+
target_kind = "external_url"
367+
target_url = cand.get("target_url")
368+
else:
369+
return JSONResponse(
370+
{"error": f"unsupported candidate kind: {kind}"},
371+
status_code=400)
372+
349373
confidence = cand.get("extra", {}).get("confidence")
350374
eng = get_engine()
351375
import uuid as _u
352376
ref_id = str(_u.uuid4())
377+
# Fix #5: inserted_by/inserted_at instead of verified_by/verified_at;
378+
# verification_status defaults to 'pending' via DB DEFAULT.
353379
with eng.begin() as conn:
354380
conn.execute(text("""
355381
INSERT INTO std_reference (
356-
id, source_clause_id, target_kind, target_clause_id,
357-
target_url, snapshot_id, citation_text, confidence,
358-
verified_by, verified_at)
359-
VALUES (:i, :sc, :tk, :tc, :tu, :sn, :ct, :cf, :u, now())
382+
id, source_clause_id, target_kind,
383+
target_clause_id, target_data_element_id, target_term_id,
384+
target_document_id, target_url, snapshot_id,
385+
citation_text, confidence,
386+
inserted_by, inserted_at)
387+
VALUES (:i, :sc, :tk,
388+
:tc, :tde, :tt,
389+
:td, :tu, :sn,
390+
:ct, :cf,
391+
:u, now())
360392
"""), {
361393
"i": ref_id, "sc": clause_id, "tk": target_kind,
362-
"tc": target_clause_id, "tu": target_url,
363-
"sn": snapshot_id, "ct": citation_text,
364-
"cf": confidence, "u": username,
394+
"tc": target_clause_id, "tde": target_data_element_id,
395+
"tt": target_term_id, "td": target_document_id,
396+
"tu": target_url, "sn": snapshot_id,
397+
"ct": citation_text, "cf": confidence,
398+
"u": username,
365399
})
366400
return JSONResponse({"ref_id": ref_id, "citation_text": citation_text})
367401

data_agent/standards_platform/tests/test_api_citation.py

Lines changed: 196 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,13 @@ def test_insert_citation_creates_std_reference(monkeypatch, engine, fresh_clause
7070
with engine.connect() as conn:
7171
row = conn.execute(text(
7272
"SELECT source_clause_id, target_clause_id, citation_text, "
73-
"confidence, verified_by FROM std_reference WHERE id=:i"
73+
"confidence, inserted_by FROM std_reference WHERE id=:i"
7474
), {"i": ref_id}).first()
7575
assert str(row.source_clause_id) == cid
7676
assert str(row.target_clause_id) == target_clause
7777
assert row.citation_text == "test snippet"
7878
assert float(row.confidence) == 0.85
79-
assert row.verified_by == "admin"
79+
assert row.inserted_by == "admin"
8080
finally:
8181
with engine.begin() as c:
8282
c.execute(text("DELETE FROM std_document WHERE id=:d"),
@@ -92,3 +92,197 @@ def test_insert_citation_rejects_invalid_kind(monkeypatch, fresh_clause):
9292
r = _client().post("/api/std/citation/insert",
9393
json={"clause_id": cid, "candidate": cand})
9494
assert r.status_code == 400
95+
96+
97+
# ---------------------------------------------------------------------------
98+
# Wave 3 v1 fixes: Fix #3 (target FK dispatch) + Fix #4 (empty text guard)
99+
# + Fix #5 (inserted_by / verification_status)
100+
# ---------------------------------------------------------------------------
101+
102+
import uuid as _uuid_mod
103+
104+
105+
def _seed_data_element(engine, version_id):
106+
de_id = str(_uuid_mod.uuid4())
107+
with engine.begin() as conn:
108+
conn.execute(text(
109+
"INSERT INTO std_data_element (id, document_version_id, "
110+
"name_zh, code) VALUES (:i, :v, '测试要素', :c)"
111+
), {"i": de_id, "v": version_id, "c": f"DE-W3-{de_id[:6]}"})
112+
return de_id
113+
114+
115+
def _seed_term(engine, version_id):
116+
t_id = str(_uuid_mod.uuid4())
117+
with engine.begin() as conn:
118+
conn.execute(text(
119+
"INSERT INTO std_term (id, document_version_id, term_code, "
120+
"name_zh, definition) VALUES (:i, :v, :tc, '测试术语', '定义')"
121+
), {"i": t_id, "v": version_id, "tc": f"TC-W3-{t_id[:6]}"})
122+
return t_id
123+
124+
125+
def test_citation_insert_data_element_target(monkeypatch, engine, fresh_clause):
126+
"""Fix #3: target_kind=std_data_element writes target_data_element_id,
127+
not target_clause_id."""
128+
cid, _, vid = fresh_clause
129+
de_id = _seed_data_element(engine, vid)
130+
_auth_user(monkeypatch, username="admin", role="admin")
131+
ref_id = None
132+
try:
133+
r = _client().post("/api/std/citation/insert", json={
134+
"clause_id": cid,
135+
"candidate": {
136+
"kind": "std_data_element",
137+
"target_id": de_id,
138+
"snippet": "数据要素引用",
139+
"extra": {"confidence": 0.85},
140+
},
141+
})
142+
assert r.status_code == 200, r.text
143+
ref_id = r.json()["ref_id"]
144+
with engine.connect() as conn:
145+
row = conn.execute(text(
146+
"SELECT target_kind, target_clause_id, target_data_element_id, "
147+
"target_term_id, inserted_by, verified_by, verification_status "
148+
"FROM std_reference WHERE id=:i"
149+
), {"i": ref_id}).first()
150+
assert row[0] == "std_data_element"
151+
assert row[1] is None
152+
assert str(row[2]) == de_id
153+
assert row[3] is None
154+
assert row[4] == "admin" # inserted_by populated (Fix #5)
155+
assert row[5] is None # verified_by NULL (Fix #5)
156+
assert row[6] == "pending" # verification_status default (Fix #5)
157+
finally:
158+
with engine.begin() as c:
159+
# ON DELETE CASCADE from std_data_element → std_reference
160+
c.execute(text("DELETE FROM std_data_element WHERE id=:i"), {"i": de_id})
161+
162+
163+
def test_citation_insert_term_target(monkeypatch, engine, fresh_clause):
164+
"""Fix #3: target_kind=std_term writes target_term_id."""
165+
cid, _, vid = fresh_clause
166+
t_id = _seed_term(engine, vid)
167+
_auth_user(monkeypatch, username="admin", role="admin")
168+
try:
169+
r = _client().post("/api/std/citation/insert", json={
170+
"clause_id": cid,
171+
"candidate": {
172+
"kind": "std_term",
173+
"target_id": t_id,
174+
"snippet": "术语引用",
175+
"extra": {"confidence": 0.75},
176+
},
177+
})
178+
assert r.status_code == 200, r.text
179+
ref_id = r.json()["ref_id"]
180+
with engine.connect() as conn:
181+
row = conn.execute(text(
182+
"SELECT target_kind, target_term_id, target_clause_id "
183+
"FROM std_reference WHERE id=:i"
184+
), {"i": ref_id}).first()
185+
assert row[0] == "std_term"
186+
assert str(row[1]) == t_id
187+
assert row[2] is None
188+
finally:
189+
with engine.begin() as c:
190+
# ON DELETE CASCADE from std_term → std_reference
191+
c.execute(text("DELETE FROM std_term WHERE id=:i"), {"i": t_id})
192+
193+
194+
def test_citation_insert_clause_target_still_works(monkeypatch, engine, fresh_clause):
195+
"""Regression: target_kind=std_clause still works post-fix."""
196+
cid, _, _ = fresh_clause
197+
_auth_user(monkeypatch, username="admin", role="admin")
198+
ref_id = None
199+
try:
200+
r = _client().post("/api/std/citation/insert", json={
201+
"clause_id": cid,
202+
"candidate": {
203+
"kind": "std_clause",
204+
"target_id": cid, # self-reference for test purposes
205+
"snippet": "条款引用",
206+
"extra": {"confidence": 0.9},
207+
},
208+
})
209+
assert r.status_code == 200, r.text
210+
ref_id = r.json()["ref_id"]
211+
finally:
212+
if ref_id:
213+
with engine.begin() as c:
214+
c.execute(text("DELETE FROM std_reference WHERE id=:i"), {"i": ref_id})
215+
216+
217+
def test_citation_insert_empty_text_rejected(monkeypatch, fresh_clause):
218+
"""Fix #4: empty citation_text returns 400."""
219+
cid, _, _ = fresh_clause
220+
_auth_user(monkeypatch, username="admin", role="admin")
221+
r = _client().post("/api/std/citation/insert", json={
222+
"clause_id": cid,
223+
"candidate": {
224+
"kind": "std_clause",
225+
"target_id": cid,
226+
"snippet": "",
227+
"extra": {"confidence": 0.5},
228+
},
229+
})
230+
assert r.status_code == 400
231+
assert "citation_text" in r.json().get("error", "")
232+
233+
234+
def test_citation_insert_whitespace_text_rejected(monkeypatch, fresh_clause):
235+
"""Fix #4: whitespace-only citation_text returns 400."""
236+
cid, _, _ = fresh_clause
237+
_auth_user(monkeypatch, username="admin", role="admin")
238+
r = _client().post("/api/std/citation/insert", json={
239+
"clause_id": cid,
240+
"candidate": {
241+
"kind": "std_clause",
242+
"target_id": cid,
243+
"snippet": " \n\t",
244+
"extra": {"confidence": 0.5},
245+
},
246+
})
247+
assert r.status_code == 400
248+
249+
250+
def test_citation_insert_web_snapshot_target(monkeypatch, engine, fresh_clause):
251+
"""Regression: web_snapshot target writes target_url and snapshot_id."""
252+
cid, _, _ = fresh_clause
253+
snap_id = str(_uuid_mod.uuid4())
254+
with engine.begin() as conn:
255+
conn.execute(text(
256+
"INSERT INTO std_web_snapshot (id, url, http_status, "
257+
"extracted_text) VALUES (:i, 'https://example.com/x', 200, "
258+
"'snippet')"
259+
), {"i": snap_id})
260+
_auth_user(monkeypatch, username="admin", role="admin")
261+
ref_id = None
262+
try:
263+
r = _client().post("/api/std/citation/insert", json={
264+
"clause_id": cid,
265+
"candidate": {
266+
"kind": "web_snapshot",
267+
"target_id": snap_id,
268+
"target_url": "https://example.com/x",
269+
"snippet": "网页引用",
270+
"extra": {"confidence": 0.6},
271+
},
272+
})
273+
assert r.status_code == 200, r.text
274+
ref_id = r.json()["ref_id"]
275+
with engine.connect() as conn:
276+
row = conn.execute(text(
277+
"SELECT target_kind, target_url, snapshot_id "
278+
"FROM std_reference WHERE id=:i"
279+
), {"i": ref_id}).first()
280+
assert row[0] == "web_snapshot"
281+
assert row[1] == "https://example.com/x"
282+
assert str(row[2]) == snap_id
283+
finally:
284+
if ref_id:
285+
with engine.begin() as c:
286+
c.execute(text("DELETE FROM std_reference WHERE id=:i"), {"i": ref_id})
287+
with engine.begin() as c:
288+
c.execute(text("DELETE FROM std_web_snapshot WHERE id=:i"), {"i": snap_id})

0 commit comments

Comments
 (0)