Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
116 changes: 116 additions & 0 deletions backend/tests/test_llm_structured_output_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Provider-agnostic structured payload validation contracts.

These tests intentionally validate Naruon's payload models without asserting a
provider URL, model id, API key, OpenAI SDK transport, or contextual-orchestrator
wire shape. Runtime transport authority belongs to the released
contextual-orchestrator consumer contract rather than this repository.
"""

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