Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
126 changes: 126 additions & 0 deletions backend-ai/app/clients/supabase_client.py
Original file line number Diff line number Diff line change
@@ -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."""

Expand All @@ -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 = """
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Comment thread
coderabbitai[bot] marked this conversation as resolved.

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:
Expand Down
33 changes: 33 additions & 0 deletions backend-ai/app/graph/nodes/classify_intent.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@
from app.graph.state import AgentState, Intent


KOREAN_LEGAL_KEYWORDS = [
"법",
"권리",
"돌려받",
"반환",
"대항력",
"우선변제",
"최우선변제",
"임대차",
"임차권",
"임대인",
"임차인",
"계약갱신",
"묵시적 갱신",
"전세사기",
]
KOREAN_PROPERTY_KEYWORDS = [
"추천",
"찾아",
"매물",
"원룸",
"오피스텔",
"아파트",
"월세",
"전세",
"관악구",
]


LEGAL_KEYWORDS = [
"법",
"계약",
Expand All @@ -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):
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions backend-ai/tests/test_agent_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
101 changes: 100 additions & 1 deletion backend-ai/tests/test_legal_retriever.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)