Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## [Unreleased]
- LLM structured-output 계약은 알 수 없는 필드와 암묵적 타입 변환뿐 아니라 범위를 벗어나거나 유한하지 않은 project graph confidence도 provider 경계에서 거부합니다. 잘못된 모델 값을 0 또는 1로 보정해 신뢰 근거처럼 저장하지 않습니다.
- 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다.
- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.

Expand Down
4 changes: 3 additions & 1 deletion backend/services/llm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from core.exceptions import LLMServiceError
from services.circuit_breaker import provider_circuit_breaker
from services.retry import retry_transient
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from services.llm_provider_urls import build_llm_provider_http_client

logger = logging.getLogger(__name__)
Expand All @@ -26,6 +26,8 @@
class ExtractionResult(BaseModel):
"""Result model for email extraction."""

model_config = ConfigDict(extra="forbid", strict=True)

summary: str
action_items: list[str]
provenance: str | None = None
Expand Down
23 changes: 17 additions & 6 deletions backend/services/project_graph/llm_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from typing import Iterable

from openai import AsyncOpenAI
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, Field

from services.llm_provider_urls import build_llm_provider_http_client

Expand Down Expand Up @@ -79,25 +79,37 @@


class ExtractedObjectPayload(BaseModel):
"""One provider-produced project object before provenance validation."""

model_config = ConfigDict(extra="forbid", strict=True)

object_type: str
title: str
summary: str
source_segment_uids: list[str]
confidence: float
confidence: float = Field(ge=0.0, le=1.0, allow_inf_nan=False)
# Stable within-response handle the model assigns so relations can reference
# objects before their persisted uid exists. Optional for backward
# compatibility; when omitted the object's position supplies the handle.
local_key: str = ""


class ExtractedRelationPayload(BaseModel):
"""One provider-produced relation before endpoint validation."""

model_config = ConfigDict(extra="forbid", strict=True)

source_local_key: str
target_local_key: str
relation_type: str
confidence: float
confidence: float = Field(ge=0.0, le=1.0, allow_inf_nan=False)


class ExtractionPayload(BaseModel):
"""Strict provider response envelope for project graph extraction."""

model_config = ConfigDict(extra="forbid", strict=True)

objects: list[ExtractedObjectPayload]
relations: list[ExtractedRelationPayload] = []

Expand Down Expand Up @@ -211,7 +223,6 @@ def _validated_objects(
if not title or not summary:
continue
primary = segments_by_uid[cited[0]]
confidence = min(max(candidate.confidence, 0.0), 1.0)
local_key = candidate.local_key.strip() or f"__object_{position}"
objects.append(
(
Expand All @@ -222,7 +233,7 @@ def _validated_objects(
title=title,
summary=summary,
source_segment_uids=cited,
confidence=confidence,
confidence=candidate.confidence,
extractor_name=LLM_EXTRACTOR_NAME,
extractor_version=LLM_EXTRACTOR_VERSION,
attributes={
Expand Down Expand Up @@ -300,7 +311,7 @@ def _relation_edges(
source_uid=source_object.uid,
target_uid=target_object.uid,
edge_type=relation_type,
confidence=min(max(relation.confidence, 0.0), 1.0),
confidence=relation.confidence,
source_segment_uids=_relation_citation(source_object, target_object),
)
)
Expand Down
6 changes: 5 additions & 1 deletion backend/services/rag_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import logging

from openai import AsyncOpenAI
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict

from core.config import settings
from services.llm_provider_urls import build_llm_provider_http_client
Expand All @@ -25,6 +25,10 @@


class GroundedAnswerPayload(BaseModel):
"""Strict provider response before citation validation and attribution."""

model_config = ConfigDict(extra="forbid", strict=True)

answer: str
cited_email_ids: list[int]

Expand Down
110 changes: 110 additions & 0 deletions backend/tests/test_llm_structured_output_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Structured payload and OpenAI JSON Schema wire contracts."""

import pytest
from pydantic import BaseModel, ValidationError

from services.llm_service import ExtractionResult
from services.project_graph.llm_extractor import ExtractionPayload
from services.rag_service import GroundedAnswerPayload


@pytest.mark.parametrize(
("payload_model", "valid_payload"),
[
(ExtractionResult, {"summary": "s", "action_items": []}),
(ExtractionPayload, {"objects": [], "relations": []}),
(GroundedAnswerPayload, {"answer": "a", "cited_email_ids": []}),
],
)
def test_structured_payload_models_forbid_unknown_top_level_fields(
payload_model: type[BaseModel],
valid_payload: dict[str, object],
) -> None:
"""Reject fields outside the product-owned structured payload schema."""

schema = payload_model.model_json_schema()

assert schema["additionalProperties"] is False
with pytest.raises(ValidationError):
payload_model.model_validate({**valid_payload, "unexpected_field": True})


@pytest.mark.parametrize(
"invalid_payload",
[
{
"objects": [
{
"object_type": "requirement",
"title": "t",
"summary": "s",
"source_segment_uids": ["segment-1"],
"confidence": 0.8,
"unexpected_field": True,
}
],
"relations": [],
},
{
"objects": [],
"relations": [
{
"source_local_key": "a",
"target_local_key": "b",
"relation_type": "depends_on",
"confidence": 0.8,
"unexpected_field": True,
}
],
},
],
)
def test_project_graph_nested_payloads_forbid_unknown_fields(
invalid_payload: dict[str, object],
) -> None:
"""Keep nested project-graph object and relation schemas fail closed."""

with pytest.raises(ValidationError):
ExtractionPayload.model_validate(invalid_payload)


def test_structured_payload_models_reject_scalar_coercion() -> None:
"""Keep machine-readable identifiers strict instead of coercing strings."""

with pytest.raises(ValidationError):
GroundedAnswerPayload.model_validate(
{"answer": "grounded", "cited_email_ids": ["123"]}
)

with pytest.raises(ValidationError):
ExtractionResult.model_validate(
{"summary": "s", "action_items": [], "confidence": "90"}
)


@pytest.mark.parametrize("confidence", [-0.1, 1.1, float("nan"), float("inf")])
@pytest.mark.parametrize("payload_kind", ["object", "relation"])
def test_project_graph_payload_rejects_invalid_confidence(
confidence: float,
payload_kind: str,
) -> None:
"""Reject out-of-range or non-finite model confidence at the wire boundary."""

object_payload = {
"object_type": "requirement",
"title": "t",
"summary": "s",
"source_segment_uids": ["segment-1"],
"confidence": confidence if payload_kind == "object" else 0.8,
}
relation_payload = {
"source_local_key": "a",
"target_local_key": "b",
"relation_type": "depends_on",
"confidence": confidence if payload_kind == "relation" else 0.8,
}

with pytest.raises(ValidationError):
ExtractionPayload.model_validate(
{"objects": [object_payload], "relations": [relation_payload]}
)
8 changes: 4 additions & 4 deletions backend/tests/test_project_graph_llm_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ async def test_objects_citing_unknown_segments_are_dropped(monkeypatch):


@pytest.mark.asyncio
async def test_unknown_type_dropped_and_confidence_clamped(monkeypatch):
async def test_unknown_type_is_dropped(monkeypatch):
monkeypatch.setattr(
llm_extractor,
"_call_llm",
Expand All @@ -148,10 +148,10 @@ async def test_unknown_type_dropped_and_confidence_clamped(monkeypatch):
),
llm_extractor.ExtractedObjectPayload(
object_type="milestone",
title="Overconfident",
title="Grounded milestone",
summary="Due next week.",
source_segment_uids=["seg1"],
confidence=7.5,
confidence=0.75,
),
)
),
Expand All @@ -163,7 +163,7 @@ async def test_unknown_type_dropped_and_confidence_clamped(monkeypatch):

assert len(result.objects) == 1
assert result.objects[0].object_type.value == "milestone"
assert result.objects[0].confidence == 1.0
assert result.objects[0].confidence == 0.75


@pytest.mark.asyncio
Expand Down
Loading