diff --git a/app/agent.py b/app/agent.py index 2e77443..5aff8fc 100644 --- a/app/agent.py +++ b/app/agent.py @@ -6,6 +6,8 @@ from typing import Any +_STRUCTURAL_LABELS = {"작업 내용:", "목적/배경:", "상세 내용:", "완료 조건:"} + class AgentError(RuntimeError): pass @@ -112,7 +114,12 @@ def _parse_plan_payload(payload: dict[str, Any], source_lines: list[str]) -> Pla raise AgentError("invalid excluded item") covered.add(source_index) excluded.append(text.strip()) - if covered != set(range(len(source_lines))): + required = { + index + for index, line in enumerate(source_lines) + if line.strip() not in _STRUCTURAL_LABELS + } + if not required.issubset(covered): raise AgentError("planning response dropped input lines") return Plan(tasks=tasks, excluded=excluded) @@ -152,13 +159,19 @@ def plan(self, raw_input: str) -> Plan: Rules: - Every source_index must appear in at least one task's source_indices or in excluded. - Reuse a source_index across tasks only when that single line explicitly contains separate deliverables. -- Group a task title, its explanatory lines, and its completion criteria into one task. -- Each genuinely independent deliverable becomes a separate task. Do not turn every detail or completion-criteria bullet into a separate task. +- First identify deliverable candidates, then attach context and acceptance criteria to each candidate. +- A single `제목:` block is not proof that all details belong to one task. +- Split candidates when any one is true: they belong to different repositories or owner teams; each can use a separate PR or merge; one can be postponed while another is still completed; each has independent acceptance criteria or verification; or each can be independently deployed or rolled back. +- Group candidates only when all are true: they share one owner area; they form one PR or deployment unit; no detail has useful value alone; and one verification result decides completion of the whole group. +- When a title or detail joins different actions with `및`, `·`, or `/`, re-check whether those actions are independent deliverables. Do not preserve an umbrella title merely because the input used one title block. +- Mandatory example: Documentation contracts, repository settings, and an AI-agent harness are separate deliverable candidates because each has a useful artifact and independent verification. Input containing those three categories MUST produce three tasks: documentation and contracts; repository configuration; AI-agent harness. A shared umbrella purpose or a possible single PR does not override this rule. Attach each checklist or verification line to its matching task; MUST NOT produce a fourth task from an acceptance criterion. +- Counterexample: build, push, and digest verification for one image deployment are steps of one task because they form one release unit and have no useful independent completion. +- Do not turn implementation steps or acceptance-criteria bullets for one deliverable into separate tasks. - By default, separate standalone action lines are separate tasks, especially short unindented lines like `cowork 고도화` and `지라 + 깃허브 연동`. -- Group lines only when their structure clearly marks them as context for a task, such as `작업 내용:`, `목적/배경:`, `상세 내용:`, `완료 조건:`, or bullets and checklist items under those sections. -- Each repeated `제목:` line starts a new task; attach the following `작업 내용:`, `목적/배경:`, `상세 내용:`, and `완료 조건:` sections to that task until the next `제목:` line. +- Structured labels such as `작업 내용:`, `목적/배경:`, `상세 내용:`, and `완료 조건:` mark context, not an unconditional instruction to merge every detail under them. +- Each repeated `제목:` line always starts a new task; a single `제목:` block may still produce multiple tasks when its details meet the independence criteria above. - Treat `- [ ]` lines under `완료 조건:` as acceptance criteria, not as separate tasks, and preserve them under the `## 완료 조건` heading in description. -- Never merge separate standalone action lines merely because they belong to the same project or theme. +- Never merge separate standalone action lines merely because they belong to the same project, theme, or umbrella title. - A task may reference multiple related source lines. Split a single source line only when it clearly contains separate deliverables. - Exclude completed items (- [x], strikethrough, 완료), schedules/meetings, and notes/thoughts. - Summary is one concise Korean action phrase, maximum 255 characters. @@ -170,13 +183,26 @@ def plan(self, raw_input: str) -> Plan: INPUT_LINES_JSON={encoded} """ payload = self._run(prompt, max_turns=3) - try: - return _parse_plan_payload(payload, source_lines) - except AgentError: - correction_prompt = ( - prompt - + "\nschema correction retry: Return the exact top-level wrapper with only " - '"tasks" and "excluded" arrays. Never return a single task object.' - ) - corrected_payload = self._run(correction_prompt, max_turns=2) - return _parse_plan_payload(corrected_payload, source_lines) + required_indices = [ + index + for index, line in enumerate(source_lines) + if line.strip() not in _STRUCTURAL_LABELS + ] + for attempt in range(3): + try: + return _parse_plan_payload(payload, source_lines) + except AgentError as exc: + if attempt == 2: + raise + correction_prompt = ( + prompt + + f"\nValidation failure: {exc}" + + f"\nRequired content source_indices: {required_indices}." + + " Every required index must appear in at least one task or excluded item." + + " Attach missing context and acceptance criteria to the matching task;" + + " do not add a task solely to cover an acceptance criterion." + + " Return the exact top-level wrapper with only " + + '"tasks" and "excluded" arrays. Never return a single task object.' + ) + payload = self._run(correction_prompt, max_turns=2) + raise AgentError("planning validation failed") diff --git a/app/api_agent.py b/app/api_agent.py index 292f3fc..f5ac68a 100644 --- a/app/api_agent.py +++ b/app/api_agent.py @@ -36,13 +36,17 @@ def _run(self, prompt: str, *, max_turns: int) -> dict[str, Any]: self.settings.gms_api_url, headers={ "Content-Type": "application/json", - "x-api-key": self.settings.gms_api_key, - "anthropic-version": "2023-06-01", + "Authorization": f"Bearer {self.settings.gms_api_key}", }, json={ "model": self.settings.gms_model, - "max_tokens": 2048, - "messages": [{"role": "user", "content": prompt}], + "messages": [ + { + "role": "developer", + "content": "Answer in Korean. Return only the JSON object requested by the user message.", + }, + {"role": "user", "content": prompt}, + ], }, ) except httpx.TimeoutException as exc: @@ -54,13 +58,10 @@ def _run(self, prompt: str, *, max_turns: int) -> dict[str, Any]: if response.status_code != 200: raise AgentError(f"GMS request rejected: HTTP {response.status_code}") try: - content = response.json()["content"] - text = "\n".join( - item["text"] - for item in content - if isinstance(item, dict) and item.get("type") == "text" - ) - except (ValueError, KeyError, TypeError) as exc: + text = response.json()["choices"][0]["message"]["content"] + if not isinstance(text, str) or not text.strip(): + raise TypeError + except (ValueError, KeyError, IndexError, TypeError) as exc: raise AgentError("invalid GMS response") from exc return _extract_json(text) diff --git a/app/api_smoke.py b/app/api_smoke.py index 4546ba9..a040f1b 100644 --- a/app/api_smoke.py +++ b/app/api_smoke.py @@ -6,7 +6,7 @@ from .agent import _extract_json, _parse_plan_payload -GMS_URL = "https://gms.ssafy.io/gmsapi/api.anthropic.com/v1/messages" +GMS_URL = "https://gms.ssafy.io/gmsapi/api.openai.com/v1/chat/completions" JIRA_BASE_URL = "https://ssafy.atlassian.net" PROJECT_KEY = "S15P11A705" BOARD_ID = "13941" @@ -27,14 +27,13 @@ def run_read_only_smoke( gms = http.post( GMS_URL, headers={ - "x-api-key": gms_key, - "anthropic-version": "2023-06-01", + "authorization": f"Bearer {gms_key}", "content-type": "application/json", }, json={ - "model": "claude-opus-4-8", - "max_tokens": 1024, + "model": "gpt-5.4-nano", "messages": [ + {"role": "developer", "content": "Answer in Korean. Return JSON only."}, { "role": "user", "content": ( @@ -50,14 +49,11 @@ def run_read_only_smoke( if gms.status_code != 200: raise RuntimeError(f"GMS request failed with HTTP {gms.status_code}") try: - body = gms.json() - text = next( - item["text"] - for item in body["content"] - if isinstance(item, dict) and item.get("type") == "text" - ) + text = gms.json()["choices"][0]["message"]["content"] + if not isinstance(text, str) or not text.strip(): + raise TypeError _parse_plan_payload(_extract_json(text), [source]) - except (KeyError, TypeError, ValueError, StopIteration) as exc: + except (KeyError, IndexError, TypeError, ValueError) as exc: raise RuntimeError("GMS returned an invalid structured response") from exc jira = http.get( diff --git a/app/config.py b/app/config.py index 8c1a2db..545f9d9 100644 --- a/app/config.py +++ b/app/config.py @@ -13,9 +13,9 @@ class Settings: cookie_secure: bool agent_timeout_seconds: int mattermost_webhook_url: str | None - gms_api_url: str = "https://gms.ssafy.io/gmsapi/api.anthropic.com/v1/messages" + gms_api_url: str = "https://gms.ssafy.io/gmsapi/api.openai.com/v1/chat/completions" gms_api_key: str | None = None - gms_model: str = "claude-opus-4-8" + gms_model: str = "gpt-5.4-nano" jira_email: str | None = None jira_api_token: str | None = None jira_board_id: int = 13941 @@ -55,10 +55,10 @@ def from_env(cls) -> "Settings": mattermost_webhook_url=mattermost_webhook_url, gms_api_url=os.getenv( "GMS_API_URL", - "https://gms.ssafy.io/gmsapi/api.anthropic.com/v1/messages", + "https://gms.ssafy.io/gmsapi/api.openai.com/v1/chat/completions", ), gms_api_key=gms_api_key, - gms_model=os.getenv("GMS_MODEL", "claude-opus-4-8"), + gms_model=os.getenv("GMS_MODEL", "gpt-5.4-nano"), jira_email=jira_email, jira_api_token=jira_api_token, jira_board_id=int(os.getenv("JIRA_BOARD_ID", "13941")), diff --git a/app/static/index.html b/app/static/index.html index 5909cfa..644d6db 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -92,10 +92,25 @@
', 1)[1].split( + "", 1 + )[0] + + for criterion in ( + "별도의 PR로 구현하거나 병합할 수 있음", + "하나를 연기해도 다른 작업을 완료할 수 있음", + "각각 독립적인 완료 조건과 검증 방법이 있음", + "독립적으로 배포하거나 롤백할 수 있음", + ): + assert criterion in prompt + assert "다음 조건을 모두 만족할 때만 하나의 티켓으로 묶습니다" in prompt + assert "현재 N개의 티켓 후보로 보입니다" in prompt + assert "이대로 분리할까요?" in prompt + assert "`및`, `·`, `/`" in prompt + assert "각 티켓마다 제목 항목을 다시 작성" in prompt + assert "문서 계약 작성, 저장소 설정, 에이전트 하네스 구성" in prompt + assert "반드시 3개의 별도 티켓" in prompt + assert "완료 조건을 네 번째 티켓으로 분리하지 않습니다" in prompt + assert "이미지 하나를 배포하기 위한 build, push, digest 확인" in prompt + assert "한 티켓의 세부 단계" in prompt + + def test_static_asset_urls_include_current_content_hash(): html = Path("app/static/index.html").read_text(encoding="utf-8") for asset in ("app.js", "style.css"):