From 34bcd477c6b115c81caaade2e69d99c2d233ba17 Mon Sep 17 00:00:00 2001 From: "Leo.Kim" <83855438+tpals0409@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:54:50 +0000 Subject: [PATCH] fix(S15P11A705-18): improve Cowork ticket splitting --- app/agent.py | 58 +++++++++++++----- app/api_agent.py | 23 +++---- app/api_smoke.py | 20 +++--- app/config.py | 8 +-- app/static/index.html | 25 ++++++-- tests/test_agent.py | 125 +++++++++++++++++++++++++++++++++++++- tests/test_api_clients.py | 19 +++--- tests/test_api_smoke.py | 11 ++-- tests/test_ui_contract.py | 25 ++++++++ 9 files changed, 250 insertions(+), 64 deletions(-) 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 @@

AI와 대화해서 티켓을 구체화하세요

## 티켓 분리 규칙 -1. 서로 독립적으로 구현, 검증 또는 배포할 수 있는 작업은 각각 별도 티켓으로 분리합니다. -2. 하나의 작업을 수행하기 위한 세부 단계는 별도 티켓으로 과도하게 나누지 않습니다. -3. 회의, 일정, 단순 메모, 아이디어만 있는 내용, 이미 완료된 작업은 티켓에서 제외합니다. -4. 제외 여부가 불명확하면 추측하지 말고 사용자에게 질문합니다. +1. 최종 결과를 작성하기 전에 독립 산출물 후보를 먼저 나열합니다. +2. 다음 중 하나라도 해당하면 별도 티켓 후보로 분리합니다. + - 서로 다른 저장소 또는 소유 팀이 작업함 + - 별도의 PR로 구현하거나 병합할 수 있음 + - 하나를 연기해도 다른 작업을 완료할 수 있음 + - 각각 독립적인 완료 조건과 검증 방법이 있음 + - 독립적으로 배포하거나 롤백할 수 있음 +3. 다음 조건을 모두 만족할 때만 하나의 티켓으로 묶습니다. + - 동일한 담당 영역에서 수행함 + - 하나의 PR 또는 하나의 배포 단위임 + - 세부 항목 하나만 완료해서는 독립적인 가치가 없음 + - 완료 여부를 하나의 검증 결과로 판단할 수 있음 +4. 제목이나 상세 내용에서 서로 다른 행동이 `및`, `·`, `/`로 연결되면 별도 티켓이 필요한지 다시 검토합니다. +5. 여러 후보가 있거나 분리 여부가 불명확하면 세부 질문 전에 “현재 N개의 티켓 후보로 보입니다: A, B, C. 이대로 분리할까요?”라고 한 번 확인합니다. + - 분리 예시: 문서 계약 작성, 저장소 설정, 에이전트 하네스 구성이 함께 있으면 공통 목적이나 단일 PR 가능성과 관계없이 반드시 3개의 별도 티켓으로 분리합니다. 각 체크리스트는 대응하는 구현 티켓의 완료 조건으로 붙이고, 완료 조건을 네 번째 티켓으로 분리하지 않습니다. + - 유지 예시: 이미지 하나를 배포하기 위한 build, push, digest 확인은 하나의 배포 단위를 이루므로 한 티켓의 세부 단계입니다. +6. 하나의 작업을 수행하기 위한 세부 단계와 그 완료 조건은 별도 티켓으로 과도하게 나누지 않습니다. +7. 회의, 일정, 단순 메모, 아이디어만 있는 내용, 이미 완료된 작업은 티켓에서 제외합니다. +8. 제외 여부가 불명확하면 추측하지 말고 사용자에게 질문합니다. ## 사실 기반 규칙 @@ -128,7 +143,7 @@

AI와 대화해서 티켓을 구체화하세요

- [ ] 검증 가능한 완료 기준 1 - [ ] 검증 가능한 완료 기준 2 -여러 티켓이 필요한 경우 위 형식을 반복합니다. +여러 티켓이 필요한 경우 위 형식을 반복하고, 각 티켓마다 제목 항목을 다시 작성합니다. 하나의 제목 아래에 독립 산출물을 함께 넣지 않습니다. ## 제목 작성 규칙 diff --git a/tests/test_agent.py b/tests/test_agent.py index d53e644..ab516a1 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -2,7 +2,7 @@ import pytest -from app.agent import AgentError, _parse_plan_payload +from app.agent import AgentError, PlanningAgent, _parse_plan_payload def test_planner_schema_covers_every_input_line(): @@ -46,3 +46,126 @@ def test_planner_rejects_malformed_source_indices(source_indices): } with pytest.raises(AgentError, match="invalid planned source indices"): _parse_plan_payload(payload, ["로그인 오류 재현"]) + + +def test_planner_rechecks_independent_deliverables_inside_one_title_block(): + class CapturingPlanner(PlanningAgent): + prompt = "" + + def _run(self, prompt, *, max_turns): + del max_turns + self.prompt = prompt + return { + "tasks": [ + { + "source_indices": list(range(8)), + "summary": "프론트엔드 레포 초기 세팅", + "description": "초기 세팅과 에이전트 하네스 구성", + } + ], + "excluded": [], + } + + planner = CapturingPlanner() + planner.plan( + """제목: 프론트엔드 레포 초기 세팅 및 에이전트 하네스 구성 +작업 내용: +- AGENTS.md와 API 계약 문서 작성 +- PR 템플릿과 VS Code 설정 추가 +- Claude agents와 commands 구성 +완료 조건: +- 문서와 설정이 병합됨 +- Claude Code에서 하네스가 인식됨""" + ) + + for rule in ( + "A single `제목:` block is not proof that all details belong to one task.", + "a separate PR or merge", + "postponed while another is still completed", + "independent acceptance criteria or verification", + "independently deployed or rolled back", + "`및`, `·`, or `/`", + "Documentation contracts, repository settings, and an AI-agent harness are separate deliverable candidates", + "MUST produce three tasks", + "documentation and contracts; repository configuration; AI-agent harness", + "MUST NOT produce a fourth task from an acceptance criterion", + "build, push, and digest verification for one image deployment are steps of one task", + ): + assert rule in planner.prompt + + +def test_planner_allows_omitted_structural_labels_but_keeps_content_coverage(): + lines = [ + "제목: 레포 설정", + "작업 내용:", + "- PR 템플릿 추가", + "완료 조건:", + "- PR 체크리스트 표시", + ] + payload = { + "tasks": [ + { + "source_indices": [0, 2, 4], + "summary": "PR 템플릿 설정", + "description": "PR 체크리스트가 표시되도록 설정한다.", + } + ], + "excluded": [], + } + + plan = _parse_plan_payload(payload, lines) + + assert [task.summary for task in plan.tasks] == ["PR 템플릿 설정"] + + +def test_planner_retry_names_validation_failure_and_required_indices(): + class RetryingPlanner(PlanningAgent): + prompts = [] + + def _run(self, prompt, *, max_turns): + del max_turns + self.prompts.append(prompt) + indices = [0] if len(self.prompts) == 1 else [0, 1] + return { + "tasks": [ + { + "source_indices": indices, + "summary": "레포 설정", + "description": "PR 템플릿과 VS Code 설정을 추가한다.", + } + ], + "excluded": [], + } + + planner = RetryingPlanner() + plan = planner.plan("PR 템플릿 추가\nVS Code 설정 추가") + + assert [task.summary for task in plan.tasks] == ["레포 설정"] + assert "Validation failure: planning response dropped input lines" in planner.prompts[1] + assert "Required content source_indices: [0, 1]" in planner.prompts[1] + + +def test_planner_allows_two_schema_correction_retries(): + class ThirdAttemptPlanner(PlanningAgent): + calls = 0 + + def _run(self, prompt, *, max_turns): + del prompt, max_turns + self.calls += 1 + indices = [0] if self.calls < 3 else [0, 1] + return { + "tasks": [ + { + "source_indices": indices, + "summary": "레포 설정", + "description": "레포 설정을 추가한다.", + } + ], + "excluded": [], + } + + planner = ThirdAttemptPlanner() + plan = planner.plan("PR 템플릿 추가\nVS Code 설정 추가") + + assert planner.calls == 3 + assert [task.summary for task in plan.tasks] == ["레포 설정"] diff --git a/tests/test_api_clients.py b/tests/test_api_clients.py index 6b84836..bcff388 100644 --- a/tests/test_api_clients.py +++ b/tests/test_api_clients.py @@ -18,16 +18,16 @@ def settings(tmp_path): cookie_secure=False, agent_timeout_seconds=60, mattermost_webhook_url=None, - gms_api_url="https://gms.ssafy.io/gmsapi/api.anthropic.com/v1/messages", + gms_api_url="https://gms.ssafy.io/gmsapi/api.openai.com/v1/chat/completions", gms_api_key="secret-key", - gms_model="claude-opus-4-8", + gms_model="gpt-5.4-nano", jira_email="cowork@example.com", jira_api_token="jira-token", jira_board_id=13941, ) -def test_gms_planner_uses_anthropic_messages_contract_and_closed_schema(tmp_path): +def test_gms_planner_uses_openai_chat_contract_and_closed_schema(tmp_path): observed = {} def handler(request: httpx.Request): @@ -41,7 +41,7 @@ def handler(request: httpx.Request): } return httpx.Response( 200, - json={"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}, + json={"choices": [{"message": {"content": json.dumps(payload, ensure_ascii=False)}}]}, ) client = httpx.Client(transport=httpx.MockTransport(handler)) @@ -49,10 +49,13 @@ def handler(request: httpx.Request): assert [task.summary for task in plan.tasks] == ["로그인 오류 수정"] assert plan.excluded == ["오후 회의"] - assert observed["request"].headers["x-api-key"] == "secret-key" - assert observed["request"].headers["anthropic-version"] == "2023-06-01" - assert observed["body"]["model"] == "claude-opus-4-8" - assert observed["body"]["max_tokens"] == 2048 + assert observed["request"].headers["authorization"] == "Bearer secret-key" + assert "x-api-key" not in observed["request"].headers + assert "anthropic-version" not in observed["request"].headers + assert observed["body"]["model"] == "gpt-5.4-nano" + assert [message["role"] for message in observed["body"]["messages"]] == ["developer", "user"] + assert "Answer in Korean" in observed["body"]["messages"][0]["content"] + assert set(observed["body"]) == {"model", "messages"} def test_gms_429_is_a_retryable_network_failure_without_secret_leak(tmp_path): diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index c8ec675..7bbb482 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -15,14 +15,12 @@ def handler(request: httpx.Request) -> httpx.Response: observed.append(request) if request.url.host == "gms.ssafy.io": assert request.method == "POST" - assert request.headers["x-api-key"] == "gms-test-key" + assert request.headers["authorization"] == "Bearer gms-test-key" return httpx.Response( 200, json={ - "content": [ - { - "type": "text", - "text": json.dumps( + "choices": [ + {"message": {"content": json.dumps( { "tasks": [ { @@ -33,8 +31,7 @@ def handler(request: httpx.Request) -> httpx.Response: ], "excluded": [], } - ), - } + )}} ] }, ) diff --git a/tests/test_ui_contract.py b/tests/test_ui_contract.py index b790e71..0eaeb91 100644 --- a/tests/test_ui_contract.py +++ b/tests/test_ui_contract.py @@ -100,6 +100,31 @@ def test_copied_interview_guide_keeps_parser_contract(): assert "티켓 요구사항을 파악하는 용도로만 사용합니다" in prompt +def test_copied_guide_defines_ticket_independence_and_requires_confirmation(): + html = Path("app/static/index.html").read_text(encoding="utf-8") + prompt = html.split('
', 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"):