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
12 changes: 12 additions & 0 deletions backend-ai/app/clients/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@


HttpPost = Callable[..., httpx.Response]
RENDERABLE_WORKERS = {
"PROPERTY_SEARCH",
"LEGAL_CONSULT",
"PRICE_ANALYSIS",
"SAFETY_ANALYSIS",
"GENERAL_CHAT",
}

SUPERVISOR_PROMPT = """\
다음 사용자 메시지와 지금까지 실행된 워커 목록을 보고, 다음에 호출할 워커를 결정해줘.
Expand Down Expand Up @@ -179,6 +186,11 @@ def extract_property_criteria(self, message: str) -> dict[str, Any]:

def generate_answer(self, state: AgentState) -> str:
workers_called = state.get("workers_called", [])
if not workers_called and state.get("intent"):
intent = state["intent"]
intent_worker = str(getattr(intent, "value", intent))
if intent_worker in RENDERABLE_WORKERS:
workers_called = [intent_worker]

if "GENERAL_CHAT" in workers_called:
live = self._generate_live_general_chat_answer(state)
Expand Down
2 changes: 1 addition & 1 deletion backend-ai/app/clients/spring_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def analyze_price(self, message: str, context: dict[str, Any]) -> dict[str, Any]
"query": message,
"selectedPropertyId": selected_property_id,
"summary": (
f"{building_name} 기준으로 최근 실거래 {len(transaction_items)}건과 "
f"{building_name} 기준으로 최근 거래 {len(transaction_items)}건과 "
f"지역 통계 {len(region_stats)}건을 확인했습니다."
),
"property": property_detail,
Expand Down
12 changes: 9 additions & 3 deletions backend-ai/app/clients/supabase_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,14 @@ def _similarity_search_legal_documents_pgvector(
connect_timeout=self.connect_timeout_seconds,
) as conn:
with conn.cursor() as cursor:
cursor.execute(f"SET LOCAL statement_timeout = {int(self.statement_timeout_ms)}")
cursor.execute(
"set local statement_timeout = %s",
(int(self.statement_timeout_ms),),
)
# IVFFlat 인덱스가 lists=100으로 설정돼 있으나 데이터 수가 적을 때
# 기본 probes=1이면 대부분의 클러스터를 건너뛰어 결과가 0개가 됨.
# probes를 lists 값과 동일하게 설정해 전체 인덱스를 탐색하도록 한다.
cursor.execute("SET LOCAL ivfflat.probes = 100")
cursor.execute("set local ivfflat.probes = %s", (100,))
cursor.execute(sql, (vector_literal, vector_literal, top_k))
return list(cursor.fetchall())

Expand Down Expand Up @@ -128,7 +131,10 @@ def search_properties(self, criteria: dict[str, Any], limit: int = 20) -> list[d
connect_timeout=self.connect_timeout_seconds,
) as conn:
with conn.cursor() as cursor:
cursor.execute(f"SET LOCAL statement_timeout = {int(self.statement_timeout_ms)}")
cursor.execute(
"set local statement_timeout = %s",
(int(self.statement_timeout_ms),),
)
cursor.execute(sql, params)
return list(cursor.fetchall())

Expand Down
12 changes: 12 additions & 0 deletions backend-ai/app/graph/state.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
from enum import StrEnum
from typing import Any, NotRequired, TypedDict


class Intent(StrEnum):
PROPERTY_SEARCH = "PROPERTY_SEARCH"
LEGAL_CONSULT = "LEGAL_CONSULT"
PRICE_ANALYSIS = "PRICE_ANALYSIS"
SAFETY_ANALYSIS = "SAFETY_ANALYSIS"
HUG_CALC = "HUG_CALC"
GENERAL_CHAT = "GENERAL_CHAT"
FINISH = "FINISH"


class AgentState(TypedDict):
user_id: str
session_id: str | None
message: str
context: dict[str, Any]
intent: NotRequired[str]
next_worker: NotRequired[str]
workers_called: NotRequired[list[str]]
answer: NotRequired[str]
Expand Down
8 changes: 4 additions & 4 deletions backend-ai/app/services/analysis_answer_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def generate_price_answer(self, state: AgentState) -> str:
else:
comparable_count = None
if comparable_count is not None:
facts.append(f"최근 실거래 {comparable_count}건을 기준으로 확인했습니다.")
facts.append(f"최근 거래 {comparable_count}건을 기준으로 확인했습니다.")

if transactions:
transaction = transactions[0]
Expand All @@ -41,7 +41,7 @@ def generate_price_answer(self, state: AgentState) -> str:
if area_m2 is not None:
parts.append(f"전용면적 {area_m2}㎡")
if parts:
facts.append("최근 사례는 " + ", ".join(parts) + "입니다.")
facts.append("최근 거래는 " + ", ".join(parts) + "입니다.")

if region_stats:
region_stat = region_stats[0]
Expand All @@ -61,7 +61,7 @@ def generate_price_answer(self, state: AgentState) -> str:
if not facts:
return "분석할 근거 데이터가 부족합니다. 매물을 선택하거나 시세 데이터가 쌓인 뒤 다시 확인해 주세요."

return " ".join(facts) + " 보증보험 가능 여부나 법적 판단은 포함하지 않습니다."
return " ".join(facts) + " 보증보험 가능 여부나 법적 판단은 포함하지 않았습니다."

def generate_safety_answer(self, state: AgentState) -> str:
result = self._tool_result(state, "safetyAnalysis")
Expand Down Expand Up @@ -97,7 +97,7 @@ def generate_safety_answer(self, state: AgentState) -> str:
("cctvCount300m", "CCTV"),
("bellCount300m", "비상벨"),
("lightCount300m", "보안등"),
("policeCount500m", "파출소"),
("policeCount500m", "경찰시설"),
]
for key, label in count_specs:
value = metrics.get(key)
Expand Down
10 changes: 5 additions & 5 deletions backend-ai/tests/test_agent_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def internal_api_headers() -> dict[str, str]:


def route_as(monkeypatch, *workers: str) -> None:
"""supervisor가 지정된 워커들을 순서대로 호출하고 FINISH하도록 mock."""
"""Mock supervisor routing so workers are called in the given order."""
call_count = {"n": 0}
worker_list = list(workers)

Expand Down Expand Up @@ -113,7 +113,7 @@ class FakeSpringClient:
def analyze_price(self, message: str, context: dict) -> dict:
return {
"selectedPropertyId": context["selectedPropertyId"],
"summary": "최근 실거래와 지역 통계를 확인했습니다.",
"summary": "최근 거래와 지역 통계를 확인했습니다.",
"metrics": {
"comparableTransactionCount": 2,
"regionStatCount": 1,
Expand Down Expand Up @@ -165,7 +165,7 @@ def analyze_price(self, message: str, context: dict) -> dict:
assert card["metrics"]["selectedPropertyId"] == "1"
assert card["metrics"]["comparableTransactionCount"] == 2
assert card["metrics"]["stub"] is False
assert "최근 실거래 2건" in body["answer"]
assert "최근 거래 2건" in body["answer"]
assert "지역 평균 보증금 10,500,000원" in body["answer"]


Expand Down Expand Up @@ -247,8 +247,8 @@ def analyze_safety(self, message: str, context: dict) -> dict:
assert body["analysisCards"]
card = body["analysisCards"][0]
assert card["type"] == "SAFETY"
assert card["title"]
assert card["summary"]
assert card["title"] == "안전 분석"
assert card["summary"] == "반경 500m 기준 안전 점수는 78점입니다."
assert card["metrics"]["selectedPropertyId"] == "1"
assert card["score"] == 78
assert card["metrics"]["radius"] == 500
Expand Down
31 changes: 24 additions & 7 deletions backend-ai/tests/test_analysis_answer_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def test_llm_client_generates_price_answer_from_tool_results() -> None:
"analysis_cards": [
{
"type": "PRICE",
"summary": "최근 실거래와 지역 통계를 확인했습니다.",
"summary": "최근 거래와 지역 통계를 확인했습니다.",
"metrics": {
"selectedPropertyId": "1",
"comparableTransactionCount": 2,
Expand Down Expand Up @@ -52,7 +52,7 @@ def test_llm_client_generates_price_answer_from_tool_results() -> None:
}
)

assert "최근 실거래 2건" in answer
assert "최근 거래 2건" in answer
assert "2026-05" in answer
assert "보증금 10,000,000원" in answer
assert "월세 520,000원" in answer
Expand Down Expand Up @@ -105,7 +105,7 @@ def test_llm_client_generates_safety_answer_from_tool_results() -> None:
assert "CCTV 8개" in answer
assert "비상벨 2개" in answer
assert "보안등 14개" in answer
assert "파출소 1개" in answer
assert "경찰시설 1개" in answer
assert "확정" not in answer


Expand Down Expand Up @@ -176,7 +176,7 @@ def test_llm_client_price_answer_preserves_zero_comparable_count() -> None:
{
"user_id": "user-1",
"session_id": None,
"message": "최근 거래가 있어?",
"message": "최근 거래가 없어?",
"context": {"selectedPropertyId": "1"},
"intent": Intent.PRICE_ANALYSIS,
"analysis_cards": [
Expand Down Expand Up @@ -204,9 +204,9 @@ def test_llm_client_price_answer_preserves_zero_comparable_count() -> None:
}
)

assert "최근 실거래 0건" in answer
assert "최근 실거래 1건" not in answer
assert "최근 실거래 7건" not in answer
assert "최근 거래 0건" in answer
assert "최근 거래 1건" not in answer
assert "최근 거래 7건" not in answer
assert "2026-05" in answer


Expand Down Expand Up @@ -249,3 +249,20 @@ def test_llm_client_safety_answer_preserves_zero_score_from_tool_result() -> Non
assert "88점" not in answer
assert "77점" not in answer
assert "반경 300m" not in answer


def test_llm_client_does_not_promote_hug_calc_intent_to_renderable_worker() -> None:
answer = LLMClient(api_key="").generate_answer(
{
"user_id": "user-1",
"session_id": None,
"message": "HUG 보증 가능해?",
"context": {},
"intent": Intent.HUG_CALC,
"analysis_cards": [],
"tool_results": {},
}
)

assert "질문 의도를" in answer
assert "HUG 보증 가능" not in answer
79 changes: 79 additions & 0 deletions backend-ai/tests/test_safety_analysis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from app.graph.nodes import safety_analysis as safety_analysis_module
from app.graph.nodes.safety_analysis import safety_analysis
from app.graph.state import Intent
from app.services.analysis_answer_service import AnalysisAnswerService


def test_safety_analysis_uses_precomputed_spring_summary(monkeypatch) -> None:
class FakeSpringClient:
def analyze_safety(self, message: str, context: dict) -> dict:
assert message == "이 매물 주변 안전은 어때?"
assert context["selectedPropertyId"] == "7"
return {
"selectedPropertyId": "7",
"summary": "반경 500m 기준 안전 점수는 78점입니다.",
"score": 78,
"metrics": {
"radius": 500,
"cctvCount300m": 8,
"bellCount300m": 2,
"lightCount300m": 14,
"policeCount500m": 1,
},
"safetySummary": {
"propertyId": 7,
"radius": 500,
"safetyScore": 78,
"cctvCount300m": 8,
"bellCount300m": 2,
"lightCount300m": 14,
"policeCount500m": 1,
},
"stub": False,
}

monkeypatch.setattr(safety_analysis_module, "SpringClient", FakeSpringClient)

state = safety_analysis(
{
"user_id": "user-1",
"session_id": None,
"message": "이 매물 주변 안전은 어때?",
"context": {"selectedPropertyId": "7"},
"intent": Intent.SAFETY_ANALYSIS,
"workers_called": [],
"analysis_cards": [],
"tool_results": {},
}
)

card = state["analysis_cards"][0]
assert card["type"] == "SAFETY"
assert card["title"] == "안전 분석"
assert card["summary"] == "반경 500m 기준 안전 점수는 78점입니다."
assert card["score"] == 78
assert card["metrics"]["radius"] == 500
assert card["metrics"]["cctvCount300m"] == 8
assert state["tool_results"]["safetyAnalysis"]["safetySummary"]["safetyScore"] == 78

answer = AnalysisAnswerService().generate_safety_answer(state)
assert "안전 점수 78점" in answer
assert "비상벨 2개" in answer
assert "보안등 14개" in answer
assert "경찰시설 1개" in answer


def test_safety_answer_handles_missing_summary() -> None:
answer = AnalysisAnswerService().generate_safety_answer(
{
"user_id": "user-1",
"session_id": None,
"message": "안전 분석해줘",
"context": {},
"intent": Intent.SAFETY_ANALYSIS,
"analysis_cards": [],
"tool_results": {"safetyAnalysis": {"metrics": {}}},
}
)

assert answer == "분석할 근거 데이터가 부족합니다. 매물을 선택하거나 안전 데이터가 쌓인 뒤 다시 확인해 주세요."
6 changes: 5 additions & 1 deletion backend-ai/tests/test_spring_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def fake_get(url: str, params: dict[str, Any] | None = None, timeout: float = 0)

assert result["selectedPropertyId"] == "7"
assert result["stub"] is False
assert result["summary"] == "Green Villa 기준으로 최근 거래 1건과 지역 통계 1건을 확인했습니다."
assert result["metrics"]["comparableTransactionCount"] == 1
assert result["metrics"]["regionStatCount"] == 1
assert result["metrics"]["buildingStatCount"] == 1
Expand Down Expand Up @@ -90,6 +91,8 @@ def fake_get(url: str, params: dict[str, Any] | None = None, timeout: float = 0)

assert result["selectedPropertyId"] == "7"
assert result["score"] == 78
assert result["summary"] == "반경 500m 기준 안전 점수는 78점입니다."
assert result["safetySummary"]["safetyScore"] == 78
assert result["metrics"]["radius"] == 500
assert result["metrics"]["cctvCount300m"] == 8
assert calls == [
Expand All @@ -110,6 +113,7 @@ def fake_get(url: str, **kwargs: Any) -> FakeResponse:

assert result["requiresSelection"] is True
assert result["selectedPropertyId"] is None
assert result["summary"] == "분석할 매물을 먼저 선택해 주세요."
assert calls == []


Expand All @@ -124,4 +128,4 @@ def fake_get(url: str, **kwargs: Any) -> FakeResponse:
assert result["selectedPropertyId"] == "7"
assert result["error"] == "SPRING_API_UNAVAILABLE"
assert result["stub"] is False
assert result["summary"]
assert result["summary"] == "선택한 매물의 안전 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."
18 changes: 18 additions & 0 deletions docs/06_EXTERNAL_APIS.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,21 @@ instead of calling public APIs during user requests.
| SafetyMap police facility IF_0036 | `POLICE` | `safety.data.safemap-police-url`, default `https://www.safemap.go.kr/openapi2/IF_0036` | XML | Uses `safety.data.safemap-service-key` or `SAFEMAP_SERVICE_KEY`; `x` is longitude and `y` is latitude. |

Common paging config: `safety.data.page-size` defaults to `1000`.

## F-4 Safety API Operations

The F-4 MVP uses public safety APIs only in backend batch jobs. Runtime user requests read Spring Boot DB-backed APIs only.

| Data | Public source format | Stored table | Runtime use |
| --- | --- | --- | --- |
| CCTV | CSV | `safety_facility` | Map overlay and safety score count within 300m. |
| Emergency bell | JSON or XML depending on configured endpoint | `safety_facility` | Safety score count within 300m. |
| Security light | JSON | `safety_facility` | Safety score count within 300m. |
| Police/security facility | SafetyMap XML | `safety_facility` | Safety score count within 500m. |

Operators must configure service keys as environment variables:

- `PUBLIC_DATA_SERVICE_KEY` for public-data endpoints that require a service key.
- `SAFEMAP_SERVICE_KEY` for the SafetyMap police/security facility source.

Do not expose these keys to the frontend. The frontend and backend-ai call Spring Boot APIs only. WMS-based safety layers are not part of the MVP stored-data flow.
19 changes: 19 additions & 0 deletions docs/07_DOMAIN_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,22 @@ F-1 MVP 생성 매물은 `properties`에 다음 기준으로 저장합니다.
- 기존 DB 데이터는 삭제하지 않고 upsert

raw XML, JSONL, geocoding cache, SQL chunk 같은 파일은 로컬 파이프라인 산출물입니다. git에 커밋하지 않으며, 운영 수집 job이 같은 역할을 대체하면 삭제해도 됩니다.

## F-4 Safety Score Stored Model

`SafetyFacility` stores normalized point data from monthly safety facility ingestion. The MVP score flow uses these stored points only; it does not call public APIs during map, detail, chat, or AI analysis requests.

`PropertyScoreStat` stores precomputed per-property score data:

| Field | Meaning |
| --- | --- |
| `property_id` | Target property ID. |
| `safety_score` | Rounded weighted score from CCTV, emergency bell, security light, and police/security facility counts. |
| `price_score` | Preserved by the safety score batch; populated by price scoring when available. |
| `cctv_count_300m` | CCTV count within 300m. |
| `bell_count_300m` | Emergency bell count within 300m. |
| `light_count_300m` | Security light count within 300m. |
| `police_count_500m` | Police/security facility count within 500m. |
| `updated_at` | Last score-stat update timestamp. |

The safety score batch upserts `safety_score` and the safety count fields while preserving existing `price_score`. New score-stat rows may have `price_score = null` until the price scoring flow fills it.
Loading