feat(curator): 4계층 picks + tier_rationale + narrative.retrospective 스키마
This commit is contained in:
@@ -17,25 +17,42 @@ class Pick(BaseModel):
|
|||||||
return sorted(v)
|
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):
|
class Narrative(BaseModel):
|
||||||
headline: str
|
headline: str
|
||||||
summary_3lines: List[str] = Field(min_length=3, max_length=3)
|
summary_3lines: List[str] = Field(min_length=3, max_length=3)
|
||||||
hot_cold_comment: str = ""
|
hot_cold_comment: str = ""
|
||||||
warnings: str = ""
|
warnings: str = ""
|
||||||
|
retrospective: str = Field(default="", max_length=80)
|
||||||
|
|
||||||
|
|
||||||
class CuratorOutput(BaseModel):
|
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
|
narrative: Narrative
|
||||||
confidence: int = Field(ge=0, le=100)
|
confidence: int = Field(ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
def validate_response(data: dict, candidate_numbers: List[List[int]]) -> CuratorOutput:
|
def validate_response(data: dict, candidate_numbers: List[List[int]]) -> CuratorOutput:
|
||||||
out = CuratorOutput.model_validate(data)
|
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}
|
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:
|
if tuple(p.numbers) not in candidate_set:
|
||||||
raise ValueError(f"pick {p.numbers} not in candidates")
|
raise ValueError(f"pick {p.numbers} not in candidates")
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -1,60 +1,55 @@
|
|||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from app.curator.schema import validate_response, CuratorOutput
|
from app.curator.schema import validate_response
|
||||||
|
|
||||||
|
|
||||||
CANDIDATE_NUMBERS = [
|
def _pick(nums, role="안정"):
|
||||||
[1, 2, 3, 4, 5, 6],
|
return {"numbers": nums, "risk_tag": role, "reason": "x"}
|
||||||
[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 _valid_payload():
|
def _make_payload(core, bonus, ext, pool):
|
||||||
return {
|
return {
|
||||||
"picks": [
|
"core_picks": core, "bonus_picks": bonus,
|
||||||
{"numbers": s, "risk_tag": "안정", "reason": "test"}
|
"extended_picks": ext, "pool_picks": pool,
|
||||||
for s in CANDIDATE_NUMBERS[:5]
|
"tier_rationale": {"bonus": "a", "extended": "b", "pool": "c"},
|
||||||
],
|
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"headline": "h", "summary_3lines": ["a", "b", "c"],
|
"headline": "h",
|
||||||
"hot_cold_comment": "hc", "warnings": "",
|
"summary_3lines": ["1", "2", "3"],
|
||||||
|
"retrospective": "지난주 평균 1.8",
|
||||||
},
|
},
|
||||||
"confidence": 80,
|
"confidence": 70,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_valid_payload_passes():
|
def test_valid_4tier():
|
||||||
result = validate_response(_valid_payload(), CANDIDATE_NUMBERS)
|
pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
|
||||||
assert isinstance(result, CuratorOutput)
|
cores = [_pick(pool[i]) for i in range(5)]
|
||||||
assert len(result.picks) == 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():
|
def test_duplicate_pick_rejected():
|
||||||
bad = _valid_payload()
|
pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
|
||||||
bad["picks"][0]["numbers"] = [40, 41, 42, 43, 44, 45] # valid numbers but not in candidates
|
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"):
|
with pytest.raises(ValueError, match="not in candidates"):
|
||||||
validate_response(bad, CANDIDATE_NUMBERS)
|
validate_response(_make_payload(cores, bonus, ext, pl), pool)
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user