From 9fb1c37eae1f545499f401fd03469086c3bc4897 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 11 May 2026 08:46:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(curator):=204=EA=B3=84=EC=B8=B5=20picks=20?= =?UTF-8?q?+=20tier=5Frationale=20+=20narrative.retrospective=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent-office/app/curator/schema.py | 25 +++++-- agent-office/tests/test_curator_schema.py | 87 +++++++++++------------ 2 files changed, 62 insertions(+), 50 deletions(-) diff --git a/agent-office/app/curator/schema.py b/agent-office/app/curator/schema.py index 9eb87c7..2ebb92a 100644 --- a/agent-office/app/curator/schema.py +++ b/agent-office/app/curator/schema.py @@ -17,25 +17,42 @@ class Pick(BaseModel): return sorted(v) +class TierRationale(BaseModel): + bonus: str = Field(max_length=40) + extended: str = Field(max_length=40) + pool: str = Field(max_length=40) + + class Narrative(BaseModel): headline: str summary_3lines: List[str] = Field(min_length=3, max_length=3) hot_cold_comment: str = "" warnings: str = "" + retrospective: str = Field(default="", max_length=80) class CuratorOutput(BaseModel): - picks: List[Pick] + core_picks: List[Pick] = Field(min_length=5, max_length=5) + bonus_picks: List[Pick] = Field(min_length=5, max_length=5) + extended_picks: List[Pick] = Field(min_length=5, max_length=5) + pool_picks: List[Pick] = Field(min_length=5, max_length=5) + tier_rationale: TierRationale narrative: Narrative confidence: int = Field(ge=0, le=100) def validate_response(data: dict, candidate_numbers: List[List[int]]) -> CuratorOutput: out = CuratorOutput.model_validate(data) - if len(out.picks) != 5: - raise ValueError("picks must have exactly 5 sets") candidate_set = {tuple(sorted(c)) for c in candidate_numbers} - for p in out.picks: + all_picks = ( + out.core_picks + out.bonus_picks + out.extended_picks + out.pool_picks + ) + # 중복 픽 검증 + pick_keys = [tuple(p.numbers) for p in all_picks] + if len(pick_keys) != len(set(pick_keys)): + raise ValueError("duplicate picks across tiers") + # 후보에 없는 번호 조합 금지 + for p in all_picks: if tuple(p.numbers) not in candidate_set: raise ValueError(f"pick {p.numbers} not in candidates") return out diff --git a/agent-office/tests/test_curator_schema.py b/agent-office/tests/test_curator_schema.py index 6ee6cb4..f919ae2 100644 --- a/agent-office/tests/test_curator_schema.py +++ b/agent-office/tests/test_curator_schema.py @@ -1,60 +1,55 @@ +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + import pytest -from app.curator.schema import validate_response, CuratorOutput +from app.curator.schema import validate_response -CANDIDATE_NUMBERS = [ - [1, 2, 3, 4, 5, 6], - [7, 8, 9, 10, 11, 12], - [13, 14, 15, 16, 17, 18], - [19, 20, 21, 22, 23, 24], - [25, 26, 27, 28, 29, 30], - [31, 32, 33, 34, 35, 36], -] +def _pick(nums, role="안정"): + return {"numbers": nums, "risk_tag": role, "reason": "x"} -def _valid_payload(): +def _make_payload(core, bonus, ext, pool): return { - "picks": [ - {"numbers": s, "risk_tag": "안정", "reason": "test"} - for s in CANDIDATE_NUMBERS[:5] - ], + "core_picks": core, "bonus_picks": bonus, + "extended_picks": ext, "pool_picks": pool, + "tier_rationale": {"bonus": "a", "extended": "b", "pool": "c"}, "narrative": { - "headline": "h", "summary_3lines": ["a", "b", "c"], - "hot_cold_comment": "hc", "warnings": "", + "headline": "h", + "summary_3lines": ["1", "2", "3"], + "retrospective": "지난주 평균 1.8", }, - "confidence": 80, + "confidence": 70, } -def test_valid_payload_passes(): - result = validate_response(_valid_payload(), CANDIDATE_NUMBERS) - assert isinstance(result, CuratorOutput) - assert len(result.picks) == 5 +def test_valid_4tier(): + pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)] + cores = [_pick(pool[i]) for i in range(5)] + bonus = [_pick(pool[i]) for i in range(5, 10)] + ext = [_pick(pool[i]) for i in range(10, 15)] + pl = [_pick(pool[i]) for i in range(15, 20)] + out = validate_response(_make_payload(cores, bonus, ext, pl), pool) + assert len(out.core_picks) == 5 + assert out.narrative.retrospective.startswith("지난주") -def test_rejects_number_out_of_candidates(): - bad = _valid_payload() - bad["picks"][0]["numbers"] = [40, 41, 42, 43, 44, 45] # valid numbers but not in candidates +def test_duplicate_pick_rejected(): + pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)] + cores = [_pick(pool[0])] * 5 # 중복 + bonus = [_pick(pool[i]) for i in range(5, 10)] + ext = [_pick(pool[i]) for i in range(10, 15)] + pl = [_pick(pool[i]) for i in range(15, 20)] + with pytest.raises(ValueError, match="duplicate"): + validate_response(_make_payload(cores, bonus, ext, pl), pool) + + +def test_pick_not_in_candidates_rejected(): + pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)] + foreign = [40, 41, 42, 43, 44, 45] + cores = [_pick(foreign)] + [_pick(pool[i]) for i in range(1, 5)] + bonus = [_pick(pool[i]) for i in range(5, 10)] + ext = [_pick(pool[i]) for i in range(10, 15)] + pl = [_pick(pool[i]) for i in range(15, 20)] with pytest.raises(ValueError, match="not in candidates"): - validate_response(bad, CANDIDATE_NUMBERS) - - -def test_rejects_wrong_pick_count(): - bad = _valid_payload() - bad["picks"] = bad["picks"][:3] - with pytest.raises(ValueError, match="exactly 5"): - validate_response(bad, CANDIDATE_NUMBERS) - - -def test_rejects_duplicate_numbers_within_set(): - bad = _valid_payload() - bad["picks"][0]["numbers"] = [1, 1, 2, 3, 4, 5] - with pytest.raises(ValueError): - validate_response(bad, CANDIDATE_NUMBERS) - - -def test_rejects_invalid_risk_tag(): - bad = _valid_payload() - bad["picks"][0]["risk_tag"] = "미친" - with pytest.raises(ValueError): - validate_response(bad, CANDIDATE_NUMBERS) + validate_response(_make_payload(cores, bonus, ext, pl), pool)