Fix comparison pipeline event and transaction handling
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
"""Конфигурация приложения — все настройки в одном месте."""
|
"""Конфигурация приложения — все настройки в одном месте."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
VERSION = "2.0.20"
|
VERSION = "2.0.21"
|
||||||
|
|
||||||
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
|
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
|
||||||
LLM_KEY = os.getenv("LLM_API_KEY", "")
|
LLM_KEY = os.getenv("LLM_API_KEY", "")
|
||||||
|
|||||||
@@ -10,12 +10,29 @@ import sqlite3
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
DB_PATH = "/tmp/contracts.db"
|
DB_PATH = "/tmp/contracts.db"
|
||||||
_db_session_key = None
|
_db_session_key = None
|
||||||
_local = threading.local()
|
_local = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction():
|
||||||
|
"""Run DB writes atomically on the current thread connection."""
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
_local.in_transaction = True
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
_local.in_transaction = False
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""Создать/пересоздать БД + схему. Вызывается при старте и после cleanup."""
|
"""Создать/пересоздать БД + схему. Вызывается при старте и после cleanup."""
|
||||||
global _db_session_key
|
global _db_session_key
|
||||||
@@ -171,6 +188,7 @@ def execute(sql, params=None):
|
|||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
sql = _pg_to_sqlite(sql)
|
sql = _pg_to_sqlite(sql)
|
||||||
cur = conn.execute(sql, params or [])
|
cur = conn.execute(sql, params or [])
|
||||||
|
if not getattr(_local, "in_transaction", False):
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cur.rowcount
|
return cur.rowcount
|
||||||
|
|
||||||
|
|||||||
+30
-15
@@ -1,6 +1,6 @@
|
|||||||
"""spec_events — event sourcing: apply ops, reset contract."""
|
"""spec_events — event sourcing: apply ops, reset contract."""
|
||||||
import json, uuid
|
import json, uuid
|
||||||
from db.connection import query, execute, get_conn
|
from db.connection import query, execute, get_conn, transaction
|
||||||
|
|
||||||
|
|
||||||
def reset(contract_id):
|
def reset(contract_id):
|
||||||
@@ -27,9 +27,15 @@ def get_next_seq(contract_id):
|
|||||||
|
|
||||||
def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
|
def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
|
||||||
"""Apply ADD/UPDATE/DELETE ops. Returns summary dict."""
|
"""Apply ADD/UPDATE/DELETE ops. Returns summary dict."""
|
||||||
|
with transaction():
|
||||||
|
return _apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
|
||||||
added = 0
|
added = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
deleted = 0
|
deleted = 0
|
||||||
|
unresolved = 0
|
||||||
seq = get_next_seq(contract_id)
|
seq = get_next_seq(contract_id)
|
||||||
|
|
||||||
for op in ops:
|
for op in ops:
|
||||||
@@ -42,6 +48,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
|
|||||||
# ADD without name → UNRESOLVED
|
# ADD without name → UNRESOLVED
|
||||||
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "ADD with empty name")
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "ADD with empty name")
|
||||||
seq += 1
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
continue
|
continue
|
||||||
name_hash = _hash(name, nr.get("date_start"))
|
name_hash = _hash(name, nr.get("date_start"))
|
||||||
execute(
|
execute(
|
||||||
@@ -65,6 +72,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
|
|||||||
if not th:
|
if not th:
|
||||||
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "UPDATE with empty target_hash")
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "UPDATE with empty target_hash")
|
||||||
seq += 1
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
continue
|
continue
|
||||||
execute(
|
execute(
|
||||||
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
||||||
@@ -86,6 +94,7 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
|
|||||||
if not th:
|
if not th:
|
||||||
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "DELETE with empty target_hash")
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, "DELETE with empty target_hash")
|
||||||
seq += 1
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
continue
|
continue
|
||||||
execute(
|
execute(
|
||||||
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
||||||
@@ -104,27 +113,19 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
|
|||||||
|
|
||||||
elif action == "UNRESOLVED":
|
elif action == "UNRESOLVED":
|
||||||
# Log but don't apply
|
# Log but don't apply
|
||||||
execute(
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
|
||||||
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
op.get("reason", op.get("comment", "")))
|
||||||
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
|
||||||
VALUES (%s, %s, %s, %s, 'UNRESOLVED', %s, %s, %s, 'unresolved', %s, %s, %s)""",
|
|
||||||
(
|
|
||||||
str(uuid.uuid4()), contract_id, supplement_id, seq,
|
|
||||||
op.get("target_hash", ""),
|
|
||||||
json.dumps(op.get("new_values", {}), ensure_ascii=False),
|
|
||||||
op.get("reason", op.get("comment", "")),
|
|
||||||
prompt_id, document_id,
|
|
||||||
json.dumps(raw_llm_response, ensure_ascii=False),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
seq += 1
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Unknown action — log as UNRESOLVED
|
# Unknown action — log as UNRESOLVED
|
||||||
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
|
_log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response,
|
||||||
f"unknown action: {action}")
|
f"unknown action: {action}")
|
||||||
|
seq += 1
|
||||||
|
unresolved += 1
|
||||||
|
|
||||||
return {"added": added, "updated": updated, "deleted": deleted}
|
return {"added": added, "updated": updated, "deleted": deleted, "unresolved": unresolved}
|
||||||
|
|
||||||
|
|
||||||
def _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, reason):
|
def _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, reason):
|
||||||
@@ -187,6 +188,20 @@ def _update_spec_current(contract_id, name_hash, new_values):
|
|||||||
sets.append(f"{field} = %s")
|
sets.append(f"{field} = %s")
|
||||||
params.append(new_values[field])
|
params.append(new_values[field])
|
||||||
if sets:
|
if sets:
|
||||||
|
if "name" in new_values or "date_start" in new_values:
|
||||||
|
updated_name = new_values.get("name")
|
||||||
|
updated_date = new_values.get("date_start")
|
||||||
|
if updated_name is None or updated_date is None:
|
||||||
|
current = query(
|
||||||
|
"SELECT name, date_start FROM spec_current WHERE contract_id = %s AND name_hash = %s",
|
||||||
|
(contract_id, name_hash),
|
||||||
|
)
|
||||||
|
if current:
|
||||||
|
updated_name = updated_name if updated_name is not None else current[0]["name"]
|
||||||
|
updated_date = updated_date if updated_date is not None else current[0]["date_start"]
|
||||||
|
if updated_name is not None:
|
||||||
|
sets.append("name_hash = %s")
|
||||||
|
params.append(_hash(updated_name, updated_date))
|
||||||
sets.append("updated_at = datetime('now')")
|
sets.append("updated_at = datetime('now')")
|
||||||
params.extend([contract_id, name_hash])
|
params.extend([contract_id, name_hash])
|
||||||
execute(
|
execute(
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
|||||||
ops = result.get("ops", [])
|
ops = result.get("ops", [])
|
||||||
mode = result.get("mode", "llm")
|
mode = result.get("mode", "llm")
|
||||||
|
|
||||||
|
if mode == "full_replace" and not ops:
|
||||||
|
yield {
|
||||||
|
"type": "extract_error",
|
||||||
|
"supplement_id": sid,
|
||||||
|
"filename": filename,
|
||||||
|
"error": "full_replace returned empty ops",
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
# Трансляция target_id ("r1","r2"...) → target_hash (name_hash из current_spec).
|
# Трансляция target_id ("r1","r2"...) → target_hash (name_hash из current_spec).
|
||||||
# LLM возвращает target_id, а apply_ops() читает target_hash — без этого UPDATE/DELETE уходят в UNRESOLVED.
|
# LLM возвращает target_id, а apply_ops() читает target_hash — без этого UPDATE/DELETE уходят в UNRESOLVED.
|
||||||
for _op in ops:
|
for _op in ops:
|
||||||
@@ -120,7 +129,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Arithmetic check
|
# Arithmetic check
|
||||||
check_arithmetic(ops)
|
arithmetic_warnings = check_arithmetic(ops)
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "applied",
|
"type": "applied",
|
||||||
@@ -128,6 +137,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
|||||||
"filename": filename,
|
"filename": filename,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"ops": applied_ops,
|
"ops": applied_ops,
|
||||||
|
"arithmetic_warnings": arithmetic_warnings,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -139,7 +149,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
|||||||
}
|
}
|
||||||
|
|
||||||
total_time = round(time.time() - t0, 1)
|
total_time = round(time.time() - t0, 1)
|
||||||
yield {"type": "complete", "total_time_s": total_time}
|
yield {"type": "done", "total_time_s": total_time}
|
||||||
|
|
||||||
|
|
||||||
def _elements_to_text(ej):
|
def _elements_to_text(ej):
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img src="/static/logo.svg" alt="Nubes">
|
<img src="/static/logo.svg" alt="Nubes">
|
||||||
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.20</span></span>
|
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.21</span></span>
|
||||||
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
||||||
<span id="stepUpload">○ Загрузка</span><span>→</span>
|
<span id="stepUpload">○ Загрузка</span><span>→</span>
|
||||||
<span id="stepClassify">○ Классификация</span><span>→</span>
|
<span id="stepClassify">○ Классификация</span><span>→</span>
|
||||||
@@ -224,11 +224,11 @@
|
|||||||
import { listZipFiles } from '/upload/zip/list_zip_files.js';
|
import { listZipFiles } from '/upload/zip/list_zip_files.js';
|
||||||
window.listZipFiles = listZipFiles;
|
window.listZipFiles = listZipFiles;
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/state.js?v=2.0.20"></script>
|
<script src="/static/state.js?v=2.0.21"></script>
|
||||||
<script src="/static/app_utils.js?v=2.0.20"></script>
|
<script src="/static/app_utils.js?v=2.0.21"></script>
|
||||||
<script src="/static/files.js?v=2.0.20"></script>
|
<script src="/static/files.js?v=2.0.21"></script>
|
||||||
<script src="/static/groups.js?v=2.0.20"></script>
|
<script src="/static/groups.js?v=2.0.21"></script>
|
||||||
<script src="/static/compare.js?v=2.0.20"></script>
|
<script src="/static/compare.js?v=2.0.21"></script>
|
||||||
<script src="/static/app.js?v=2.0.20"></script>
|
<script src="/static/app.js?v=2.0.21"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class TestApplyOpsAdd:
|
|||||||
def test_add(self, db):
|
def test_add(self, db):
|
||||||
ops = [{"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2025-01-01"}}]
|
ops = [{"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2025-01-01"}}]
|
||||||
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
||||||
assert s == {"added": 1, "updated": 0, "deleted": 0}
|
assert s == {"added": 1, "updated": 0, "deleted": 0, "unresolved": 0}
|
||||||
rows = spec_current.list_by_contract(CID)
|
rows = spec_current.list_by_contract(CID)
|
||||||
assert len(rows) == 1
|
assert len(rows) == 1
|
||||||
assert rows[0]["name"] == "Аренда стойко-места"
|
assert rows[0]["name"] == "Аренда стойко-места"
|
||||||
@@ -27,7 +27,7 @@ class TestApplyOpsAdd:
|
|||||||
def test_add_empty_name_unresolved(self, db):
|
def test_add_empty_name_unresolved(self, db):
|
||||||
ops = [{"action": "ADD", "new_row": {"name": ""}}]
|
ops = [{"action": "ADD", "new_row": {"name": ""}}]
|
||||||
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
|
||||||
assert s == {"added": 0, "updated": 0, "deleted": 0}
|
assert s == {"added": 0, "updated": 0, "deleted": 0, "unresolved": 1}
|
||||||
assert spec_current.list_by_contract(CID) == []
|
assert spec_current.list_by_contract(CID) == []
|
||||||
|
|
||||||
def test_add_multiple(self, db):
|
def test_add_multiple(self, db):
|
||||||
|
|||||||
Reference in New Issue
Block a user