refs #740: add Few-shot management tab

This commit is contained in:
devmrko
2026-07-28 15:46:53 +09:00
parent 041ab4f287
commit 9d2c75459f
4 changed files with 336 additions and 3 deletions

View File

@@ -21,7 +21,12 @@ class QaHistoryStoreError(RuntimeError):
QUESTION_TABLE = "SG_AI_QA_QUESTION"
ANSWER_TABLE = "SG_AI_QA_ANSWER"
VECTOR_EXAMPLE_TABLE = "SG_QA_VECTOR_EXAMPLE"
HISTORICAL_RUN_KEY = "HISTORICAL:2026-07-21:term-dict-final-v2"
REFERENCE_STATUSES = ("DRAFT", "APPROVED", "RETIRED")
REFERENCE_KINDS = ("SQL_TEMPLATE", "NO_TARGET", "OBJECT_UNAVAILABLE", "METADATA_POLICY")
TARGET_TYPES = ("NONE", "SINGLE", "MULTI", "ALL", "ANY")
INSPECTION_STATUSES = ("PENDING", "REVIEW", "VERIFIED", "RETIRED")
def _env_value(name: str, env_file: Path | None = None) -> str:
@@ -110,6 +115,32 @@ def _answer_record(row: Mapping[str, Any]) -> dict[str, Any]:
}
def _vector_example_record(row: Mapping[str, Any]) -> dict[str, Any]:
return {
"example_id": int(row["example_id"]),
"question": str(row.get("question") or ""),
"answer_sql": str(row.get("answer_sql") or ""),
"answer_text": str(row.get("answer_text") or ""),
"reference_status": str(row.get("reference_status") or "DRAFT"),
"reference_kind": str(row.get("reference_kind") or "SQL_TEMPLATE"),
"target_type": str(row.get("target_type") or "ANY"),
"object_role": str(row.get("object_role") or ""),
"inspection_status": str(row.get("inspection_status") or "PENDING"),
"inspection_note": str(row.get("inspection_note") or ""),
"verified_at": str(row.get("verified_at") or ""),
"verified_by": str(row.get("verified_by") or ""),
"source_case_id": str(row.get("source_case_id") or ""),
"source_type": str(row.get("source_type") or ""),
}
def _choice(value: object, choices: tuple[str, ...], field: str) -> str:
normalized = str(value or "").strip().upper()
if normalized not in choices:
raise QaHistoryStoreError(f"{field} 값을 확인해 주세요.")
return normalized
class QaHistoryStore:
def __init__(self, *, env_file: Path | None = None) -> None:
self._env_file = env_file
@@ -225,6 +256,106 @@ class QaHistoryStore:
row = cursor.fetchone()
return question_from_record(_record_from_cursor(cursor, row)) if row else None
def list_vector_examples_by_case(self, question_code: str) -> list[dict[str, Any]]:
"""Return governed few-shot examples linked to one customer QA case."""
case_id = str(question_code or "").strip().upper()
if not case_id:
raise QaHistoryStoreError("질문 코드를 확인해 주세요.")
sql = f"""
SELECT example_id, question, answer_sql, answer_text,
reference_status, reference_kind, target_type, object_role,
inspection_status, inspection_note,
TO_CHAR(verified_at AT TIME ZONE 'Asia/Seoul',
'YYYY-MM-DD HH24:MI:SS TZH:TZM') AS verified_at,
verified_by, source_case_id, source_type
FROM {VECTOR_EXAMPLE_TABLE}
WHERE source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = :source_case_id
ORDER BY example_id
"""
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, {"source_case_id": case_id})
rows = [_record_from_cursor(cursor, row) for row in cursor]
return [_vector_example_record(row) for row in rows]
def update_vector_example(
self,
*,
example_id: int,
source_case_id: str,
question: str,
answer_sql: str,
answer_text: str,
reference_status: str,
reference_kind: str,
target_type: str,
object_role: str,
inspection_status: str,
inspection_note: str,
verified_by: str,
) -> None:
"""Update one customer example and rebuild its vector document atomically."""
case_id = str(source_case_id or "").strip().upper()
normalized_question = str(question or "").strip()
normalized_sql = str(answer_sql or "").strip()
if not case_id or not normalized_question or not normalized_sql:
raise QaHistoryStoreError("질문과 예제 SQL은 비워둘 수 없습니다.")
status = _choice(reference_status, REFERENCE_STATUSES, "검색 상태")
kind = _choice(reference_kind, REFERENCE_KINDS, "참조 종류")
target = _choice(target_type, TARGET_TYPES, "적용 범위")
inspection = _choice(inspection_status, INSPECTION_STATUSES, "검토 상태")
sql = f"""
UPDATE {VECTOR_EXAMPLE_TABLE}
SET question = :question,
answer_sql = :answer_sql,
answer_text = :answer_text,
embedding_input = TO_CLOB('Customer QA question: ') || :question
|| TO_CLOB(CHR(10) || 'Few-shot SQL template: ') || :answer_sql
|| CASE WHEN :answer_text IS NULL THEN NULL
ELSE TO_CLOB(CHR(10) || 'Few-shot answer: ') || :answer_text END,
embedding = DBMS_VECTOR.UTL_TO_EMBEDDING(
TO_CLOB('Customer QA question: ') || :question
|| TO_CLOB(CHR(10) || 'Few-shot SQL template: ') || :answer_sql
|| CASE WHEN :answer_text IS NULL THEN NULL
ELSE TO_CLOB(CHR(10) || 'Few-shot answer: ') || :answer_text END,
JSON(sg_qa_vector_params('search_document'))
),
reference_status = :reference_status,
reference_kind = :reference_kind,
target_type = :target_type,
object_role = :object_role,
inspection_status = :inspection_status,
inspection_note = :inspection_note,
verified_at = CASE WHEN :reference_status = 'APPROVED'
THEN SYSTIMESTAMP ELSE NULL END,
verified_by = CASE WHEN :reference_status = 'APPROVED'
THEN :verified_by ELSE NULL END
WHERE example_id = :example_id
AND source_type = 'CUSTOMER_QA_BENCHMARK'
AND source_case_id = :source_case_id
"""
binds = {
"example_id": int(example_id),
"source_case_id": case_id,
"question": normalized_question,
"answer_sql": normalized_sql,
"answer_text": str(answer_text or "").strip() or None,
"reference_status": status,
"reference_kind": kind,
"target_type": target,
"object_role": str(object_role or "").strip()[:64] or None,
"inspection_status": inspection,
"inspection_note": str(inspection_note or "").strip() or None,
"verified_by": str(verified_by or "").strip()[:128] or "PORTAL_OPERATOR",
}
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, binds)
if cursor.rowcount != 1:
raise QaHistoryStoreError("수정할 질문별 few-shot 예제를 찾지 못했습니다.")
connection.commit()
def list_answers(self, question_id: int, *, limit: int = 30) -> list[dict[str, Any]]:
sql = f"""
SELECT answer_seq, question_id, answer_kind, run_key, conversation_id,