Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
58 changes: 42 additions & 16 deletions app/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from typing import Any

_STRUCTURAL_LABELS = {"작업 내용:", "목적/배경:", "상세 내용:", "완료 조건:"}


class AgentError(RuntimeError):
pass
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand All @@ -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")
23 changes: 12 additions & 11 deletions app/api_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)

Expand Down
20 changes: 8 additions & 12 deletions app/api_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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": (
Expand All @@ -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(
Expand Down
8 changes: 4 additions & 4 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")),
Expand Down
25 changes: 20 additions & 5 deletions app/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,25 @@ <h1 id="input-guide-title">AI와 대화해서 티켓을 구체화하세요</h1>

## 티켓 분리 규칙

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

## 사실 기반 규칙

Expand Down Expand Up @@ -128,7 +143,7 @@ <h1 id="input-guide-title">AI와 대화해서 티켓을 구체화하세요</h1>
- [ ] 검증 가능한 완료 기준 1
- [ ] 검증 가능한 완료 기준 2

여러 티켓이 필요한 경우 위 형식을 반복합니다.
여러 티켓이 필요한 경우 위 형식을 반복하고, 각 티켓마다 제목 항목을 다시 작성합니다. 하나의 제목 아래에 독립 산출물을 함께 넣지 않습니다.

## 제목 작성 규칙

Expand Down
125 changes: 124 additions & 1 deletion tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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] == ["레포 설정"]
Loading