diff --git a/.gitignore b/.gitignore index 6684681..2045555 100644 --- a/.gitignore +++ b/.gitignore @@ -9,11 +9,16 @@ data/raw/ data/seed/ data/pipeline/ data/cache/ +data/legal/ data/output/ !data/reference/ !data/reference/*.example.csv !data/**/README.md +# Local run logs +run-logs/ +.codex/run-logs/ + # Generated SQL seed artifacts database/seed/ diff --git a/backend-ai/app/clients/supabase_client.py b/backend-ai/app/clients/supabase_client.py index 8bc068f..0c560af 100644 --- a/backend-ai/app/clients/supabase_client.py +++ b/backend-ai/app/clients/supabase_client.py @@ -1,12 +1,18 @@ import json +import math +import re from typing import Any +import httpx import psycopg from psycopg.rows import dict_row from app.core.config import get_settings +REST_PAGE_SIZE = 1000 + + class SupabaseVectorClient: """Client boundary for Supabase PostgreSQL + pgvector legal search.""" @@ -32,6 +38,25 @@ def similarity_search_legal_documents( self, query_embedding: list[float], top_k: int = 3, + ) -> list[dict[str, Any]]: + try: + return self._similarity_search_legal_documents_pgvector( + query_embedding=query_embedding, + top_k=top_k, + ) + except psycopg.OperationalError: + settings = get_settings() + if settings.app_env.lower() in {"prod", "production"}: + raise + return self._similarity_search_legal_documents_rest( + query_embedding=query_embedding, + top_k=top_k, + ) + + def _similarity_search_legal_documents_pgvector( + self, + query_embedding: list[float], + top_k: int, ) -> list[dict[str, Any]]: vector_literal = to_pgvector_literal(query_embedding) sql = """ @@ -59,6 +84,60 @@ def similarity_search_legal_documents( cursor.execute(sql, (vector_literal, vector_literal, top_k)) return list(cursor.fetchall()) + def _similarity_search_legal_documents_rest( + self, + query_embedding: list[float], + top_k: int, + ) -> list[dict[str, Any]]: + settings = get_settings() + project_ref = supabase_project_ref(settings.supabase_db_url) + if not project_ref or not settings.supabase_service_role_key: + raise RuntimeError("Supabase REST fallback is not configured.") + + url = f"https://{project_ref}.supabase.co/rest/v1/legal_document_chunks" + headers = { + "apikey": settings.supabase_service_role_key, + "Authorization": f"Bearer {settings.supabase_service_role_key}", + } + rows: list[dict[str, Any]] = [] + offset = 0 + while True: + response = httpx.get( + url, + headers=headers, + params={ + "select": "law_name,article_no,article_title,content,embedding", + "embedding": "not.is.null", + "limit": str(REST_PAGE_SIZE), + "offset": str(offset), + }, + timeout=self.connect_timeout_seconds + 10, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list) or not payload: + break + + for row in payload: + if not isinstance(row, dict): + continue + embedding = parse_pgvector_value(row.get("embedding")) + if not embedding: + continue + rows.append( + { + "law_name": row["law_name"], + "article_no": row["article_no"], + "article_title": row["article_title"], + "content": row["content"], + "score": cosine_similarity(query_embedding, embedding), + } + ) + if len(payload) < REST_PAGE_SIZE: + break + offset += REST_PAGE_SIZE + return sorted(rows, key=lambda item: item["score"], reverse=True)[:top_k] + def upsert_legal_document_chunks(self, rows: list[dict[str, Any]]) -> int: if not rows: return 0 @@ -142,6 +221,53 @@ def to_pgvector_literal(embedding: list[float]) -> str: return "[" + ",".join(f"{float(value):.10g}" for value in embedding) + "]" +def supabase_project_ref(database_url: str) -> str | None: + match = re.search(r"postgres(?:ql)?://([^:]+):[^@]+@", database_url) + if not match: + return None + username = match.group(1) + if "." not in username: + return None + return username.split(".", 1)[1] + + +def parse_pgvector_value(value: Any) -> list[float]: + if isinstance(value, list): + return [parsed for item in value if (parsed := parse_float_value(item)) is not None] + if not isinstance(value, str): + return [] + stripped = value.strip() + if not stripped.startswith("[") or not stripped.endswith("]"): + return [] + body = stripped[1:-1].strip() + if not body: + return [] + return [ + parsed + for item in body.split(",") + if item.strip() + if (parsed := parse_float_value(item.strip())) is not None + ] + + +def parse_float_value(value: Any) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None + + +def cosine_similarity(left: list[float], right: list[float]) -> float: + if not left or not right or len(left) != len(right): + return 0.0 + dot = sum(a * b for a, b in zip(left, right, strict=True)) + left_norm = math.sqrt(sum(value * value for value in left)) + right_norm = math.sqrt(sum(value * value for value in right)) + if left_norm == 0 or right_norm == 0: + return 0.0 + return dot / (left_norm * right_norm) + + def legal_chunk_upsert_params(row: dict[str, Any]) -> dict[str, Any]: embedding = row.get("embedding") if not isinstance(embedding, list) or not embedding: diff --git a/backend-ai/app/graph/nodes/classify_intent.py b/backend-ai/app/graph/nodes/classify_intent.py index 2d2bb48..7d3d8b7 100644 --- a/backend-ai/app/graph/nodes/classify_intent.py +++ b/backend-ai/app/graph/nodes/classify_intent.py @@ -1,6 +1,35 @@ from app.graph.state import AgentState, Intent +KOREAN_LEGAL_KEYWORDS = [ + "법", + "권리", + "돌려받", + "반환", + "대항력", + "우선변제", + "최우선변제", + "임대차", + "임차권", + "임대인", + "임차인", + "계약갱신", + "묵시적 갱신", + "전세사기", +] +KOREAN_PROPERTY_KEYWORDS = [ + "추천", + "찾아", + "매물", + "원룸", + "오피스텔", + "아파트", + "월세", + "전세", + "관악구", +] + + LEGAL_KEYWORDS = [ "법", "계약", @@ -19,6 +48,8 @@ def classify_message(message: str) -> Intent: normalized = message.lower() + if any(keyword in normalized for keyword in KOREAN_LEGAL_KEYWORDS): + return Intent.LEGAL_CONSULT if any(keyword in normalized for keyword in LEGAL_KEYWORDS): return Intent.LEGAL_CONSULT if any(keyword in normalized for keyword in HUG_KEYWORDS): @@ -27,6 +58,8 @@ def classify_message(message: str) -> Intent: return Intent.PRICE_ANALYSIS if any(keyword in normalized for keyword in SAFETY_KEYWORDS): return Intent.SAFETY_ANALYSIS + if any(keyword in normalized for keyword in KOREAN_PROPERTY_KEYWORDS): + return Intent.PROPERTY_SEARCH if any(keyword in normalized for keyword in PROPERTY_KEYWORDS): return Intent.PROPERTY_SEARCH return Intent.FALLBACK diff --git a/backend-ai/tests/test_agent_chat.py b/backend-ai/tests/test_agent_chat.py index ffe2ecc..a725969 100644 --- a/backend-ai/tests/test_agent_chat.py +++ b/backend-ai/tests/test_agent_chat.py @@ -99,3 +99,8 @@ def test_classify_intent_examples() -> None: def card_text_in_answer(answer: str, card: dict) -> bool: return card["lawName"] in answer and card["articleNo"] in answer + + +def test_classify_intent_korean_examples() -> None: + assert classify_message("관악구 보증금 5천 이하 원룸 추천해줘") == Intent.PROPERTY_SEARCH + assert classify_message("전세 보증금을 돌려받지 못하면 어떤 권리가 있나요?") == Intent.LEGAL_CONSULT diff --git a/backend-ai/tests/test_legal_retriever.py b/backend-ai/tests/test_legal_retriever.py index fe71b70..25a5e31 100644 --- a/backend-ai/tests/test_legal_retriever.py +++ b/backend-ai/tests/test_legal_retriever.py @@ -1,7 +1,9 @@ +import os + import pytest from app.clients import supabase_client as supabase_module -from app.clients.supabase_client import SupabaseVectorClient +from app.clients.supabase_client import SupabaseVectorClient, parse_pgvector_value from app.graph.nodes import legal_rag as legal_rag_module from app.graph.state import Intent from app.rag.retriever import LegalRetriever @@ -166,3 +168,100 @@ def fake_connect(database_url, **kwargs): assert calls["connect_kwargs"]["connect_timeout"] == 7 assert len(calls.get("executes", [])) >= 1 assert calls["executes"][0] == ("set local statement_timeout = %s", (3000,)) + + +def test_parse_pgvector_value_supports_postgrest_vector_strings() -> None: + assert parse_pgvector_value("[0.1,0.2,-0.3]") == [0.1, 0.2, -0.3] + assert parse_pgvector_value([1, "2.5"]) == [1.0, 2.5] + assert parse_pgvector_value("[0.1,broken,0.3]") == [0.1, 0.3] + + +def test_supabase_vector_client_falls_back_to_rest_when_pg_is_unreachable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("TEST_SUPABASE_DB_URL", "unused-test-db-url") + monkeypatch.setenv("TEST_SUPABASE_SERVICE_ROLE_KEY", "unused-test-service-role-key") + + class FakeSettings: + app_env = "local" + supabase_db_url = os.environ["TEST_SUPABASE_DB_URL"] + supabase_connect_timeout_seconds = 1 + supabase_statement_timeout_ms = 1000 + supabase_service_role_key = os.environ["TEST_SUPABASE_SERVICE_ROLE_KEY"] + + class FakeResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> list[dict]: + return [ + { + "law_name": "주택임대차보호법", + "article_no": "제3조의2", + "article_title": "보증금의 회수", + "content": "임차인은 보증금을 우선변제 받을 권리가 있다.", + "embedding": "[1,0]", + }, + { + "law_name": "전세사기피해자 지원 및 주거안정에 관한 특별법", + "article_no": "제1조", + "article_title": "목적", + "content": "전세사기피해자를 지원한다.", + "embedding": "[0,1]", + }, + ] + + calls: dict[str, object] = {} + + def fake_connect(*args, **kwargs): + raise supabase_module.psycopg.OperationalError("blocked") + + def fake_get(url, **kwargs): + calls["url"] = url + calls["kwargs"] = kwargs + return FakeResponse() + + monkeypatch.setattr(supabase_module, "get_settings", lambda: FakeSettings()) + monkeypatch.setattr(supabase_module, "supabase_project_ref", lambda database_url: "project-ref") + monkeypatch.setattr(supabase_module.psycopg, "connect", fake_connect) + monkeypatch.setattr(supabase_module.httpx, "get", fake_get) + + client = SupabaseVectorClient() + rows = client.similarity_search_legal_documents([1, 0], top_k=1) + + assert rows == [ + { + "law_name": "주택임대차보호법", + "article_no": "제3조의2", + "article_title": "보증금의 회수", + "content": "임차인은 보증금을 우선변제 받을 권리가 있다.", + "score": 1.0, + } + ] + assert calls["url"].startswith("https://project-ref.supabase.co/rest/v1/legal_document_chunks") + + +def test_supabase_vector_client_does_not_fallback_to_rest_in_production( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeSettings: + app_env = "production" + supabase_db_url = "unused-test-db-url" + supabase_connect_timeout_seconds = 1 + supabase_statement_timeout_ms = 1000 + supabase_service_role_key = "" + + def fake_connect(*args, **kwargs): + raise supabase_module.psycopg.OperationalError("blocked") + + def fake_get(*args, **kwargs): + raise AssertionError("REST fallback must not run in production.") + + monkeypatch.setattr(supabase_module, "get_settings", lambda: FakeSettings()) + monkeypatch.setattr(supabase_module.psycopg, "connect", fake_connect) + monkeypatch.setattr(supabase_module.httpx, "get", fake_get) + + client = SupabaseVectorClient() + + with pytest.raises(supabase_module.psycopg.OperationalError): + client.similarity_search_legal_documents([1, 0], top_k=1)