diff --git a/.issueops/issues/561/gates.md b/.issueops/issues/561/gates.md new file mode 100644 index 000000000..bf6ff9b8e --- /dev/null +++ b/.issueops/issues/561/gates.md @@ -0,0 +1,18 @@ +# Gates: 561 + +- [x] G1: 품질 표 테스트가 실제 이슈 발췌의 선택과 기권을 고정한다 + CHECK: go test ./internal/domain/issueopsremote/ -run TestScoreIssueOpsRemoteLabelQualityTable -count=1 -v + EXPECT: --- PASS: TestScoreIssueOpsRemoteLabelQualityTable + EVIDENCE: PASS | ok issueops/internal/domain/issueopsremote 0.142s +- [x] G2: 검증 집합 232-560 단일 라벨 이슈에서 기본 threshold로 선택 50개 이상 정답 비율 85% 이상 + CHECK: python3 scripts/autoresearch/label_score_eval.py --binary bin/issueops --min-number 232 --max-number 560 --check + EXPECT: PASS + EVIDENCE: {"min_number": 232, "max_number": 560, "n_total": 116, "n_selected": 56, "n_correct": 54, "accuracy": 0.9643} | PASS +- [x] G3: 관련 이슈 점수 명시 score CLI remote score 동작이 유지된다 + CHECK: go test ./internal/domain/issueopsremote/ ./cmd/issueops/issueopscli/remotecmd/ ./cmd/issueops/issueopscli/ -count=1 + EXPECT: ok + EVIDENCE: ok issueops/cmd/issueops/issueopscli/remotecmd 11.561s | ok issueops/cmd/issueops/issueopscli 20.922s +- [x] G4: DDD 책임 원장이 새 심볼과 스크립트를 포함한다 + CHECK: go test ./internal/architecture/ -count=1 + EXPECT: ok + EVIDENCE: ok issueops/internal/architecture 6.889s diff --git a/.issueops/issues/561/intent.md b/.issueops/issues/561/intent.md new file mode 100644 index 000000000..30d08183c --- /dev/null +++ b/.issueops/issues/561/intent.md @@ -0,0 +1,28 @@ +# 요청자 의도 계약 + +- lifecycle: io-b5a7cbecca2a +- issue: https://github.com/m16khb-org/issueops/issues/561 +- intent_class: standard + +## 원문 요청 +1 + +(직전 답변의 제안 1: "휴리스틱 라벨 점수를 먼저 고칩니다. 점수가 같을 때의 처리와 타입 휴리스틱을 손보면 Jev 없이도 다수결 수준까지는 올라갈 것으로 봅니다. 이 이슈 229개를 회귀 데이터로 쓸 수 있습니다." / "원하시면 1번부터 IssueOps 사이클로 진행하겠습니다.") + +## 해석 +issueops remote score --judge none의 결정론적 라벨 점수가 한국어 이슈에서 사실상 동작하지 않는다(기본 threshold 0.70에서 이 저장소 단일 라벨 이슈 229개 중 224개가 라벨 0개 선택, 정답 라벨 선택 4개). 라벨 점수를 한국어 어간(조사 붙은 토큰)과 라벨 간 상대 근거를 반영하도록 고쳐, 확신할 때는 정답 라벨 하나를 고르고 근거가 약하면 아무것도 고르지 않아 semantic judge로 넘기게 한다. 관련 이슈 점수, CLI/MCP 응답 필드, threshold 기본값은 바꾸지 않는다. + +## 성공 기준 +- 1) 이 저장소의 단일 라벨 이슈 중 번호 232 이상 115개(규칙 설계에 쓰지 않은 검증 집합, 제목 태그와 #번호 제거)에서 기본 threshold로 --judge none을 실행하면 라벨을 고른 이슈가 50개 이상이고, 고른 라벨이 정답과 정확히 같은 비율이 85% 이상이다(현재 229개 전체 기준 정답 선택 4개). 2) 키워드 규칙이 없는 라벨(예: question, wontfix)과 관련 이슈 점수, 명시 score 입력 동작은 기존과 같다. 3) 실제 이슈에서 뽑은 라벨 품질 표 테스트가 새 규칙의 선택·기권을 고정하고 go test ./... 가 통과한다. + +## 비목표 +- (없음) + +## 제약 +- (없음) + +## 모호함 +- (없음) + +## 읽는 규칙 +이 문서는 요청자 의도 계약이다. 원격 이슈 본문은 구현 계약이다. 두 문서가 충돌하면 구현을 시작하지 말고 충돌한 줄을 인용해 blocker로 보고한다. diff --git a/.issueops/issues/561/plan-review.md b/.issueops/issues/561/plan-review.md new file mode 100644 index 000000000..0ed4fd111 --- /dev/null +++ b/.issueops/issues/561/plan-review.md @@ -0,0 +1,12 @@ +# 계획 검토 기록 + +## 1차: 수정 요청 + +- 리뷰 모델 anthropic/claude-opus-5-5, effort high(next.review 기본값). 1차 리뷰는 계획이 remote_gaps_test.go의 선택 라벨 evidence 두 줄 단언을 놓쳐, evidence를 한 줄로 줄이면 기존 테스트와 '규칙 없는 라벨 동작 유지' 기준이 깨진다고 지적했다. +- 1차 리뷰는 '검증 집합은 설계에 쓰지 않았다'는 주장이 사실과 다르다고 지적했다. 키워드 일부는 개발 집합에 없는 일반 어휘이고, 검증 집합 수치를 규칙 확정 전에 봤으며, 개발 집합에서는 기준에 못 미친다. + +## 2차: 통과 + +- 리뷰 모델 anthropic/claude-opus-5-5, effort high(next.review 기본값). 1차 필수 결함 D1은 계획이 규칙 라벨 evidence를 두 줄로 유지하고 remote_gaps_test.go의 입력과 단언을 명시해 해소되었으며, 규칙 없는 라벨 불변도 별도 경계 테스트로 고정되었다. +- 1차 필수 결함 D2는 키워드 출처와 검증 집합 노출을 사실대로 고쳐 쓰고, 품질 표를 번호 232 미만 발췌로 제한하고, 완료 보고에 두 구간 결과를 병기하도록 해 해소되었다. +- 2차 리뷰에서 새 필수 결함은 없었다. 비차단 메모로, 전체 근거가 1개일 때 규칙 라벨 점수 합은 0이고 기권 시 evidence 형식을 구현에서 정해야 한다는 점이 남았다. diff --git a/.issueops/issues/561/plan.md b/.issueops/issues/561/plan.md new file mode 100644 index 000000000..c29c18074 --- /dev/null +++ b/.issueops/issues/561/plan.md @@ -0,0 +1,134 @@ +# #561 remote score 라벨 점수 수정 계획 + +- lifecycle ID: `io-b5a7cbecca2a` +- 이슈: https://github.com/m16khb-org/issueops/issues/561 +- 브랜치: `561-korean-label-score` (base `main`, 봉인 SHA는 record의 `branch_prepare.base_sha`) +- 요청 범위: 결정론적 라벨 점수 수정, 회귀 측정 수단, 테스트. 승인된 종료점은 draft PR 발행과 `execution complete`다. +- 세션: 워크트리 준비 뒤 환경별 자동 세션 인계를 적용한다. 인계는 요청 범위를 넓히지 않는다. + +## 문제 + +`scoreIssueOpsRemoteLabel`(internal/domain/issueopsremote/issueops_remote_score.go:120-146)은 +`0.65*overlap + 0.35*heuristic`으로 점수를 낸다(같은 파일 126-128행). `issueOpsRemoteLabelHeuristic` +(issueops_remote_score_tokens.go:50-62)은 키워드가 하나라도 토큰과 정확히 같으면 1(documentation은 0.75)을 +돌려준다. 결과: + +- 키워드만 맞으면 최대 0.35라 기본 threshold 0.70(issueops_remote.go:3)을 넘지 못한다. +- `issueOpsRemoteTokens`(issueops_remote_score_tokens.go:8-20)는 공백·구두점으로만 자르므로 "오류가"는 "오류"와 다르다. +- 실측: 단일 라벨 이슈 229개 중 224개가 라벨 0개, 정답 선택 4개. + +## 설계 + +키워드 규칙이 있는 라벨(bug, enhancement, documentation)의 점수를 "이슈 토큰 중 유형 근거 토큰에서 이 라벨이 +차지하는 비율"로 바꾼다. + +1. `issueOpsRemoteLabelKeywords`를 `map[string][]string`으로 바꾸고 키워드를 보강한다. 한글 키워드는 토큰의 + 접두와 일치하면 맞는다(조사·어미 흡수). 영어 키워드는 토큰 전체가 같아야 맞는다(`add`가 `address`에 맞지 않게). +2. 이슈 토큰 집합에서 규칙별 근거 토큰 수를 한 번 센다(`issueOpsRemoteLabelEvidence`). 한 토큰은 규칙마다 + 최대 한 번 센다. +3. 규칙이 있는 라벨의 점수 = 그 라벨 근거 수 / 전체 근거 수. 전체 근거가 2개 미만이면 0(기권). 근거 1개로 + 고른 경우의 정답률이 개발 집합(번호 232 미만)에서 6/9로, 근거 2개 이상(36/43)보다 낮았다. +4. 규칙이 없는 라벨은 점수(`0.65*overlap`, heuristic 항이 항상 0이던 값)와 evidence 두 줄(`token_overlap=%.2f`, + `type_heuristic=0.00`)을 지금과 바이트 단위로 같게 유지한다. 최대 0.65라 기본 threshold에서 선택되지 않는 것도 같다. + 명시 `score` 입력은 지금처럼 그 값을 쓴다. +5. 규칙 라벨의 evidence는 지금처럼 두 줄이다: `type_evidence=<라벨 근거>/<전체 근거>`와 + `matched_tokens=<이 라벨에 맞은 토큰, 정렬, 쉼표 구분>`. +6. 규칙 라벨은 `description`·`aliases` 겹침을 더 이상 쓰지 않는다. 규칙 세 라벨의 점수 합은 1(근거가 있을 때)이라 + 기본 threshold 0.70에서는 많아야 한 라벨만 선택된다. threshold를 0.5 이하로 낮추면 동률 두 라벨이 함께 선택될 수 있다. + +키워드 출처와 검증 집합 노출(사실 그대로): + +- 키워드는 개발 집합(번호 232 미만) 제목·본문과 개발 이슈에서 흔한 한국어·영어 결함/기능/문서 어휘에서 골랐다. 일부 + (`broken`, `feature`, `support`, `readme`, `오동작` 등)는 개발 집합에 나오지 않는 일반 어휘다. +- 최소 근거 2개 규칙은 개발 집합 수치(근거 1개 선택 6/9, 2개 이상 36/43)로 정했다. +- 검증 집합(232-560)은 규칙 확정 전에 한 번 측정했다(선택 56, 정답 54). 그 뒤 키워드와 최소 근거 규칙을 바꾸지 않는다. + 검증 집합은 완전히 독립된 평가가 아니며, 개발 집합에서는 기준에 못 미친다(선택 43, 정답률 83.7%). + 완료 보고에 두 구간 결과를 함께 적는다. + +키워드 목록: + +- bug: bug, defect, failure, fail, failed, fails, error, crash, regression, broken, 오류, 결함, 버그, 실패, 회귀, + 깨진, 깨지, 잘못, 누락, 오동작, 재현, 레이스, 교착, 손상, 유실, 막힌, 막혀, 거부되, 고친다, 수정한다 +- enhancement: feature, request, support, add, enhancement, 개선, 추가, 지원, 도입, 확장, 새로운, 신규, 기능, 강화 +- documentation: docs, documentation, readme, 문서 + +## 재사용하는 기존 구현 + +- `issueOpsRemoteTokens`(issueops_remote_score_tokens.go:8): 이슈 토큰 집합을 그대로 쓴다. 바꾸지 않으므로 관련 이슈 + 점수(`scoreIssueOpsRemoteIssue`)는 영향이 없다. +- `issueOpsRemoteOverlap`, `clampScore`, `scorePtr`, apply hint·정렬 helper(issueops_remote_score_helpers.go): 그대로 쓴다. +- 품질 표 테스트 형식은 `internal/adapter/projectdocs/projectdocs_test.go:296`의 + `TestRouteProjectDocsQualityTableHasNoRequiredOmissions`와 `testdata/route_quality_cases.json` 선례를 따른다. +- 측정 스크립트는 `scripts/autoresearch/`의 eval 스크립트 선례(doc_injection_eval.py 등, stdlib만 사용)와 + `internal/architecture/ddd_responsibility_test.go:746`의 등록 방식을 따른다. +- 새로 만드는 것: `issueOpsRemoteLabelEvidence`(근거 수 집계)와 `issueOpsRemoteKeywordMatches`(접두/정확 일치) 두 함수. + 기존 heuristic은 "한 키워드라도 맞으면 고정 점수"라 개수를 세지 못하므로 대체한다. + +## 성능 영향 + +이슈를 만들 때 한 번 도는 CLI 경로이고 hot path가 아니다. 근거 집계는 토큰 수 T × 키워드 수 K(약 50)의 +문자열 비교이며, 라벨 후보마다가 아니라 요청마다 한 번 계산한다. 기존은 라벨마다 K번 map 조회였다. +본문 1만 토큰에서도 50만 번의 짧은 비교라 체감 차이가 없다. 측정 계획: 없음(측정된 병목이 아니다). + +## 하위 호환성과 side effect + +- CLI `remote score` JSON, MCP schema, `IssueOpsRemoteScoredItem` 필드는 그대로다. golden은 바뀌지 않는다. +- 바뀌는 것은 규칙 라벨 세 개의 `score` 값과 evidence 문자열 내용이다. evidence 줄 수는 그대로 두 줄이다. + `internal/domain/issueopsremote/remote_gaps_test.go:526`의 `len(res.SelectedLabels[0].Evidence) != 2` 단언이 이 줄 수를 + 고정한다(입력 토큰 `bug`, `failure`는 새 규칙에서도 bug 2/2로 선택된다). evidence 문자열 내용을 검사하는 테스트나 소비자는 + 없다(`rg 'type_heuristic|token_overlap'` 결과 issueops_remote_score.go 한 곳). +- 기존 테스트 `TestIssueOpsRemoteLabelHeuristicMatchesKnownLabels`는 삭제되는 함수를 고정하므로 새 동작을 검사하는 + 테스트로 바꾼다. +- 저장 데이터·record schema·provider body 계약 변화는 없다. 롤백은 커밋 revert 하나다. +- DDD inventory(`internal/architecture/testdata/ddd_responsibility_inventory.json`)의 심볼 목록을 바꾼다. +- 측정 스크립트는 실행 시 `gh issue list --state all --limit 1000`과 `gh label list`로 이 저장소를 읽는다(읽기 전용, + 네트워크). 요청 파일은 임시 디렉터리에만 쓰고 지운다. 저장소·원격에는 쓰지 않는다. CI·self-verify에 연결하지 않는다. + +## 적용되는 결정과 주의사항 + +- .issueops/CONSTITUTION.md 제2장 문제 해결 원칙: "재현 근거, 호출 경로, 상태 전이, 정책 경계, 문서 계약을 따라가며 문제가 발생한 근본 원인을 확인한 뒤 그 원인을 제거한다." -> threshold를 낮추거나 출력만 고치지 않고 점수 규칙을 고친다. +- .issueops/CONSTITUTION.md 제2장: "수정 뒤에는 같은 실패가 재발하지 않음을 보여주는 targeted test, command smoke, grep evidence, 로그/상태 확인 중 작업 성격에 맞는 검증을 남긴다." -> 품질 표 테스트와 검증 집합 측정을 게이트로 둔다. +- .issueops/ARCHITECTURE.md 의존 방향 불변식: "`internal/domain`은 contract와 순수 domain helper에만 의존하고 concrete adapter나 `cmd/...`를 import하지 않는다." -> 변경은 domain 순수 함수 안에 머물고 새 import는 표준 라이브러리뿐이다. +- .issueops/CONVENTIONS.md 이슈 산출물 레이아웃: "이 표가 유일한 규정이다." -> 측정 데이터와 스크립트를 `.issueops/issues/561/`에 두지 않고 `scripts/autoresearch/`에 둔다. +- .issueops/CAUTIONS.md Universal summary: "`.issueops/*.md` 편집은 response-contract golden을 드리프트시킨다." -> 이 계획은 `.issueops/*.md`를 고치지 않는다. 문서 단계에서 고치게 되면 golden을 다시 만든다. +- .issueops/ADR.md -> .issueops/adr/2026-07-07-standalone-harness-policy.md: "issueops consumes them only through ordinary inspectable boundaries such as local files, command output, or already-configured MCP data." -> 외부 모델을 core에 넣지 않는다. 측정 스크립트도 core 경로가 아니다. +- .issueops/TESTING.md 최소 완료 기준: "Go 코드 변경의 기본 검증(`gofmt -l`, `go test -race ./...`, `go vet ./...`, architecture ratchet, operational-health 위임, golden 갱신 조건)은 [unit-and-contract.md](testing/unit-and-contract.md)가 소유하고" -> 검증 단계에서 gofmt·vet·race·architecture를 실행한다. +- .issueops/testing/unit-and-contract.md: "실패해도 원인을 알 수 없는 거대한 fixture나 broad snapshot만 둔다." (피해야 할 예) -> 품질 표는 번호 232 미만 이슈에서만 뽑은 짧은 발췌 10건 안팎으로 두고, 229개 원문(1.4MB)은 커밋하지 않는다. +- .issueops/TECH_STACK.md: "Python 검증은 stdlib suite runner와 스킬에 이미 선언된 Pydantic/Typer를 사용한다." -> 측정 스크립트는 stdlib만 쓴다. +- .issueops/AGENT_WORKFLOW.md Work 절: 대조했으나 이 변경의 범위를 정하는 항목 없음. +- .issueops/cautions/issueops-lifecycle.md: 대조했으나 없음(readiness 게이트·golden 항목은 이 변경과 무관). +- .issueops/adr/2026-09-24-issue-and-pr-bodies-are-human-documents.md: "Hashes, commit SHAs, plan and spec texts, label scores, and cleanup audits leave the bodies" -> PR 본문에 라벨 점수 표를 넣지 않고 측정 결과 요약만 쓴다. +- .issueops/adr/2026-08-22-task-gate-ledger.md: "CHECK 명령은 raw shell이 아니라 command policy 경로로 실행한다." -> 게이트 CHECK는 argv 한 줄로 쓴다. +- .issueops/adr/2026-10-06-remove-the-last-legacy-compatibility-paths-and-non-release-p.md: "remote score 입력의 related_issues/labels alias" 제거 -> 입력 키는 현재 `label_candidates`만 쓴다. +- .issueops/adr/2026-10-06-delete-re-export-shims-and-stop-naming-live-code-legacy.md: 대조했으나 없음(re-export·legacy 이름을 만들지 않는다). +- .issueops/cautions/2026-08-27-skill-without-openai-yaml-self-verify-qa-gate.md: 열지 않음: 스킬 파일을 바꾸지 않는다. + +## 구현 순서 + +1. RED: `internal/domain/issueopsremote/testdata/label_quality_cases.json`과 `TestScoreIssueOpsRemoteLabelQualityTable` 추가. + 번호 232 미만 이슈의 발췌로만 선택·기권을 고정한다(조사 붙은 한국어 bug, enhancement, documentation, 근거 엇갈림 기권, + 근거 1개 기권). 규칙 없는 라벨의 점수·evidence 불변은 같은 테스트 파일의 별도 경계 테스트로, 명시 score는 기존 + `issueops_remote_test.go`로 고정한다. 현재 코드에서 실패하는 것을 확인한다. +2. GREEN: 설계 1-6을 구현한다. `TestIssueOpsRemoteLabelHeuristicMatchesKnownLabels`를 새 함수의 경계 테스트(접두/정확 일치, + 근거 2개 미만 기권)로 바꾼다. `remote_gaps_test.go:526`의 evidence 두 줄 단언은 그대로 통과해야 한다. +3. `scripts/autoresearch/label_score_eval.py` 추가: `gh issue list --state all --limit 1000 --json number,title,body,labels`로 + 라벨이 정확히 하나인 이슈를 읽어 제목 앞 `[..]` 태그와 본문 `#번호`·GitHub 이슈/PR URL을 지우고, `gh label list`의 이름·설명을 + 후보로 넣어 `--binary`의 `remote score --judge none --json`을 실행한다(요청 파일은 임시 디렉터리). 번호 범위의 대상 수(`n_total`)· + 선택 수·정답 수·정답 비율을 JSON 한 줄로 내고, `--check`면 기준(선택 50 이상, 정답 비율 0.85 이상)을 넘을 때 마지막 줄에 + `PASS` 한 단어만 출력하고 아니면 `FAIL`과 종료 코드 1을 낸다. +4. DDD inventory와 `ddd_responsibility_test.go` 등록을 갱신한다. +5. `go build -o bin/issueops ./cmd/issueops` 후 게이트를 채운다. + +## 게이트 + +- G1: 품질 표 테스트가 실제 이슈 발췌의 선택과 기권을 고정한다(테스트가 ScoreIssueOpsRemoteCandidates를 실행해 selected_labels를 기대값과 비교) | CHECK: go test ./internal/domain/issueopsremote/ -run TestScoreIssueOpsRemoteLabelQualityTable -count=1 -v | EXPECT: --- PASS: TestScoreIssueOpsRemoteLabelQualityTable +- G2: 검증 집합(번호 232-560 단일 라벨 이슈)에서 기본 threshold로 선택 50개 이상, 정답 비율 85% 이상(실제 bin/issueops remote score를 실행해 GitHub 라벨과 비교) | CHECK: python3 scripts/autoresearch/label_score_eval.py --binary bin/issueops --min-number 232 --max-number 560 --check | EXPECT: PASS +- G3: 관련 이슈 점수·명시 score·CLI remote score 동작이 유지된다 | CHECK: go test ./internal/domain/issueopsremote/ ./cmd/issueops/issueopscli/remotecmd/ ./cmd/issueops/issueopscli/ -count=1 +- G4: DDD 책임 원장이 새 심볼과 스크립트를 포함한다 | CHECK: go test ./internal/architecture/ -count=1 + +G2는 `gh`로 GitHub를 읽으므로 `gates check --network`로 실행한다. 자식 `gh`는 `--env` 기본값(`HOME,PATH`)으로 키체인 인증을 +쓴다. G3이 기본 timeout 120초를 넘으면 `--timeout-seconds 600`으로 실행한다. + +## 검증(게이트 외) + +`gofmt -l`, `go vet ./...`, `go test ./... -count=1`, `go test -race ./... -count=1`은 7단계에서 한 번 실행한다. diff --git a/.issueops/issues/561/verified-execution-report.md b/.issueops/issues/561/verified-execution-report.md new file mode 100644 index 000000000..9aa071d47 --- /dev/null +++ b/.issueops/issues/561/verified-execution-report.md @@ -0,0 +1,135 @@ +# #561 verified-execution report + +- lifecycle: `io-b5a7cbecca2a`, branch `561-korean-label-score`, base `bac123aac302fd423673e60be0268c06696f9e13` +- 계획: `.issueops/issues/561/plan.md` +- 게이트 원장: `.issueops/issues/561/gates.md`(G1~G4 모두 충족, EVIDENCE가 정본) + +## 목표와 수용 기준 + +| 이슈 완료 기준 | 근거 | +|---|---| +| 1) 검증 집합(번호 232 이상 단일 라벨 이슈)에서 기본 threshold로 선택 50개 이상, 정답 비율 85% 이상 | G2: 선택 56, 정답 54, 96.43% | +| 2) 규칙 없는 라벨, 관련 이슈 점수, 명시 score 입력 동작 유지 | G3, `TestScoreIssueOpsRemoteLabelWithoutKeywordRuleKeepsOverlapScore` | +| 3) 실제 이슈 발췌 품질 표 테스트가 선택·기권을 고정하고 `go test ./...`가 통과한다 | G1(RED 확인 후 GREEN). 전체 테스트는 verify 단계 | + +## 구현 요약 + +- `issueops_remote_score_tokens.go`: `issueOpsRemoteLabelKeywords`를 `map[string][]string`으로 바꾸고 키워드를 보강했다. + `issueOpsRemoteKeywordMatches`(한글 접두 일치, 영어 정확 일치), `issueOpsRemoteLabelEvidence`(이슈 토큰마다 규칙별 + 최대 한 번 집계), `issueOpsRemoteLabelEvidenceScore`(라벨 근거 / 전체 근거, 전체 근거 2개 미만이면 0)를 추가하고 + `issueOpsRemoteLabelHeuristic`을 지웠다. +- `issueops_remote_score.go`: 요청마다 근거를 한 번 계산해 `scoreIssueOpsRemoteLabel`에 넘긴다. 규칙 라벨의 evidence는 + 두 줄(`type_evidence=<라벨>/<전체>`, `matched_tokens=<정렬, 쉼표 구분>`), 규칙 없는 라벨은 `0.65*overlap`과 + `token_overlap=…`, `type_heuristic=0.00`으로 기존과 같다. 명시 score는 그대로다. +- 테스트: 품질 표(`testdata/label_quality_cases.json`, 번호 232 미만 발췌 7건: 선택 4, 기권 3)와 + `TestScoreIssueOpsRemoteLabelQualityTable`, 규칙 없는 라벨 경계 테스트, 키워드 일치·근거 집계·기권 경계 테스트. +- `scripts/autoresearch/label_score_eval.py`: stdlib만 쓰는 측정 스크립트(`--check`면 마지막 줄 `PASS`/`FAIL`). +- DDD inventory와 `ddd_responsibility_test.go`의 스크립트 등록을 갱신했다. + +## 측정 결과(두 구간 병기) + +키워드와 최소 근거 2개 규칙은 번호 232 미만(개발 집합)으로 정했고, 검증 집합(232-560)은 규칙 확정 전에 한 번 측정했다. +검증 집합은 완전히 독립된 평가가 아니다. + +| 구간 | 대상(단일 라벨) | 선택 | 정답 | 정답 비율 | +|---|---|---|---|---| +| 개발 집합 (0-231) | 114 | 43 | 36 | 83.72% | +| 검증 집합 (232-560) | 116 | 56 | 54 | 96.43% | + +개발 집합은 기준(85%)에 못 미친다. 변경 전 229개 전체의 정답 선택은 4개였다. +검증 집합 대상이 계획 작성 시점의 115개보다 1개 많은 것은 측정 시점의 이슈 목록 차이다. + +## 성능 + +hot path가 아니다(이슈를 만들 때 한 번 도는 CLI 경로, 근거 집계는 토큰 수 × 키워드 약 50의 문자열 비교). 계획대로 측정하지 않았다. + +## Side effect + +- 바뀌는 것: `remote score`의 bug·enhancement·documentation 라벨 `score` 값과 evidence 문자열 내용. evidence 줄 수와 JSON 필드는 그대로다. +- 추가·변경 파일: domain 두 파일과 테스트, `testdata/label_quality_cases.json`, `scripts/autoresearch/label_score_eval.py`, + DDD inventory JSON과 등록 테스트, `.issueops/issues/561/` 추적 사본. +- 측정 스크립트는 실행 시 `gh issue list`와 `gh label list`로 이 저장소를 읽는다(읽기 전용, 네트워크). 요청 파일은 임시 디렉터리에만 + 쓰고 지운다. 원격·저장소에는 쓰지 않으며 CI와 self-verify에 연결하지 않았다. +- record schema, 저장 데이터, CLI/MCP 응답 필드, threshold 기본값, 관련 이슈 점수 변화 없음. + +## 적용한 문서 규칙 + +| 문서 경로 | 해당 문장 인용 | 이 구현에서 바뀐 결정 | +|---|---|---| +| `.issueops/CONSTITUTION.md` | "재현 근거, 호출 경로, 상태 전이, 정책 경계, 문서 계약을 따라가며 문제가 발생한 근본 원인을 확인한 뒤 그 원인을 제거한다." | threshold를 낮추지 않고 점수 규칙(근거 집계)을 고쳤다 | +| `.issueops/ARCHITECTURE.md` | "`internal/domain`은 contract와 순수 domain helper에만 의존하고 concrete" | 변경이 domain 순수 함수에 머물고 새 import는 `sort`, `unicode/utf8`뿐이다 | +| `.issueops/CONVENTIONS.md` | "새 dependency는 표준 라이브러리로 명확히 부족할 때만 추가한다." | 형태소 분석기 대신 한글 접두 일치를 썼다 | +| `.issueops/CONVENTIONS.md` | "이 표가 유일한 규정이다." (이슈 산출물 레이아웃) | 측정 스크립트를 `.issueops/issues/561/`이 아니라 `scripts/autoresearch/`에 뒀다 | +| `.issueops/TECH_STACK.md` | "Python 검증은 stdlib suite runner와 스킬에 이미 선언된 Pydantic/Typer를 사용한다." | 측정 스크립트는 stdlib만 쓴다 | +| `.issueops/testing/unit-and-contract.md` | "실패해도 원인을 알 수 없는 거대한 fixture나 broad snapshot만 둔다." (피해야 할 예) | 품질 표는 번호 232 미만 발췌 7건으로 두고 이슈 원문 전체는 커밋하지 않았다 | + +## 문서 → 구현 대조 + +| 문서 경로 | 규칙 문장 인용 | 지킴 또는 어김 | diff의 근거 위치 | +|---|---|---|---| +| `.issueops/CONVENTIONS.md` | "`internal/domain`은 contract와 순수 domain helper에만 의존하고," | 지킴 | `issueops_remote_score_tokens.go` import는 표준 라이브러리(`sort`, `strings`, `unicode`, `unicode/utf8`)뿐 | +| `.issueops/ARCHITECTURE.md` | "`internal/domain`은 contract와 순수 domain helper에만 의존하고 concrete" | 지킴 | 같은 파일. adapter·`cmd/...` import 없음 | +| `.issueops/CONVENTIONS.md` | "`--json` field는 snake_case." | 지킴(변경 없음) | CLI/MCP 응답 필드와 `IssueOpsRemoteScoredItem`을 바꾸지 않았다 | +| `.issueops/CONSTITUTION.md` | "재현 근거, 호출 경로, 상태 전이, 정책 경계, 문서 계약을 따라가며 문제가 발생한 근본 원인을 확인한 뒤 그 원인을 제거한다." | 지킴 | threshold 기본값을 건드리지 않고 `scoreIssueOpsRemoteLabel`의 점수 규칙을 고쳤다 | +| `.issueops/CAUTIONS.md` | "게이트 원장 `CHECK:`는 argv 한 줄이다." | 지킴 | `gates.md`의 CHECK 네 줄은 모두 단일 argv이고 G1~G4가 met이다 | +| `.issueops/CAUTIONS.md` | "`.issueops/*.md` 편집은 response-contract golden을 드리프트시킨다." | 해당 없음 | `.issueops/*.md` 편집 없음. 추적 사본과 report는 `.issueops/issues/561/` 아래다 | + +## 문서 반영 판정 + +`remote score` 라벨 점수의 내부 규칙(휴리스틱, `token_overlap`, `type_heuristic`)을 설명하는 운영 문서나 스킬 문구는 없다 +(`.issueops`, `skills/issueops*`, README 검색 결과 `--judge none`은 모두 `benchmark run`의 판정 모드이거나 호출 예시다). +새 구조 결정이나 재발 함정이 없어 ADR·CAUTIONS는 쓰지 않는다. 키워드 출처와 검증 집합 노출은 계획과 이 report에 남았다. + +## 구현 리뷰 + +독립 서브에이전트(omp `reviewer` 에이전트, 빈 컨텍스트, 읽기 전용)가 diff와 계획을 함께 받아 `pass`를 냈다. 필수 결함은 없었다. +계획 리뷰가 남긴 다섯 주장(evidence 두 줄, 규칙 없는 라벨 불변, 노출 공개, 품질 표 범위, 근거 1개 기권)을 코드와 직접 실행으로 확인했다. +비차단 메모 두 건은 반영했다. 스크립트의 실제와 다른 종료 코드 주석을 지웠고, 이 report의 토큰 수 서술을 고쳤다. + +리뷰어가 따로 잰 값(리뷰어 보고, 이 세션에서 재현하지 않음): 이슈 본문에 남은 라벨 판단 문장(라벨 이름과 선택·거절·threshold 어휘가 함께 있는 줄)을 +지우고 다시 쟀을 때 검증 집합은 선택 60·정답 55(91.67%), 개발 집합은 선택 46·정답 39(84.78%)였다. G2의 PASS가 본문에 새어 나간 정답에 +기대지 않는다는 확인이다. 재현율 한계(영어 키워드는 조사가 붙으면 맞지 않고, 한글 접두는 음절 단위라 `거부되`가 `거부된다`를 잡지 못한다)는 +계획이 정한 규칙대로의 동작이다. + +## 의도 대조 + +봉인 intent 문서의 성공 기준 1은 검증 집합을 "규칙 설계에 쓰지 않은" 집합이라고 적었다. 이슈 본문은 계획 리뷰의 지적으로 "규칙을 정한 개발 집합과 분리한 +검증 집합, 규칙 확정 전 한 번 측정한 뒤 규칙 동결"로 고쳐졌고(`feedback`의 `contract_change`로 기록), 구현과 report는 이슈 본문을 따른다. +intent 문서의 해당 문구는 원문 요청의 해석이라 고치지 않았다. 성공 기준 1~3은 위 `목표와 수용 기준` 표의 게이트로 모두 덮인다. + +## 발행 + +- draft PR: https://github.com/m16khb-org/issueops/pull/562 (base `main`, head `561-korean-label-score`, label `bug`, assignee `m16khb`, `Closes #561`) +- 라벨 점수가 선택한 라벨이 없어(본문이 결함·기능·문서 어휘를 함께 설명) 연결된 이슈의 라벨 `bug`를 수동 override로 복사했고, 판단은 record의 decision에 남겼다. + +## base 동기화 뒤 재검증(generation 3) + +PR #562의 첫 CI는 옛 main(`bac123aa`)의 golangci-lint 부채 90건 때문에 Lint에서 멈췄다. 그 부채는 #563(PR #564)으로 main에서 고쳐졌고, +메인 세션이 main(`a2f7dccb`)을 브랜치에 머지(`e2ce2d68`)한 뒤 완료된 generation 2를 보관하고 generation 3으로 reseed했다. +#561 소유 파일 13개는 머지가 바꾸지 않았다(`git diff a2f7dccb HEAD`가 #561 파일만 가리킨다). + +| 명령 | 결과 | +|---|---| +| `go test ./internal/domain/issueopsremote/ ./cmd/issueops/issueopscli/remotecmd/ ./cmd/issueops/issueopscli/ ./internal/architecture/ -count=1` | PASS | +| `python3 scripts/autoresearch/label_score_eval.py --binary bin/issueops --min-number 232 --max-number 560 --check` | 선택 56, 정답 54(96.43%), PASS | +| `self-verify --base-ref bac123aa…` 첫 실행(사용자 홈 그대로) | 25 step 중 24 통과. `native integration`이 `~/.omo/agent/skills/*` 부재로 실패했다. 이번 변경과 무관하게 사용자 홈의 Omo 설치가 사라진 환경 변화다 | +| 같은 `self-verify`를 CI와 같은 임시 HOME 설치(`install-native.sh --skip-build --path-mode=skip --mcp-transport=stdio`) 뒤 실행 | 27 step 중 27 통과, 최소 goal score 100 | +| CI(`e2ce2d68`) push run과 pull_request run | 둘 다 success. Format, Vet, Lint(golangci-lint v2), Build, Race test, self-verify 단계 모두 success | + +## 검증 + +| 요구한 문서 경로 | 그 문서의 해당 문장 인용 | 명령 | 결과 또는 Not Run 사유 | +|---|---|---|---| +| `.issueops/TESTING.md` | "Go 코드 변경의 기본 검증(`gofmt -l`, `go test -race ./...`, `go vet ./...`, architecture" | `./bin/issueops self-verify --base-ref bac123aa… --seed=100 --target-score=95 --llm-eval=false --json` (scratch venv의 pydantic·typer를 PATH 앞에 둠) | 27 step 중 27 통과, 최소 goal score 100. gofmt, Python 스크립트 테스트, `go test -race ./... -count=1`과 `go vet ./...`(risk QA tier), 빌드, golden 포함 | +| `.issueops/TESTING.md` | 같은 문장(`architecture ratchet`) | `go test ./internal/architecture/ -count=1` | PASS(게이트 G4와 self-verify의 전체 테스트) | +| 게이트 원장 | G1~G4의 CHECK | `issueops gates check --network --timeout-seconds 600 --file .issueops/issues/561/gates.md` | G1~G4 PASS, ALL MET(리뷰 반영 뒤 G2 명령 재실행도 PASS) | +| 스킬(`issueops-implement`) | RED 확인 | `go test ./internal/domain/issueopsremote/ -run TestScoreIssueOpsRemoteLabel` (구현 전) | FAIL(품질 표 4건 선택 실패), 경계 테스트는 PASS | +| 게이트 원장 | 개발 집합 측정 | `python3 scripts/autoresearch/label_score_eval.py --binary bin/issueops --max-number 231` | 선택 43, 정답 36 | + +첫 self-verify는 이 머신의 python3에 pydantic이 없어 `skills/slack-delegate` Python 테스트 import에서 실패했다(`ModuleNotFoundError: No module named 'pydantic'`). +이번 변경과 무관한 환경 문제이며, `python3 -m venv`로 만든 임시 환경(저장소 밖)에 pydantic·typer를 설치해 같은 명령을 다시 실행했다. 사용자 홈과 저장소는 바꾸지 않았다. + +## 실패와 재시도 + +- 규칙 없는 라벨 경계 테스트의 첫 기대값(overlap 0.43)이 틀렸다. 라벨 토큰 4개(작은 쪽) 중 3개가 이슈 토큰과 겹쳐 0.75가 맞다. +- 품질 표 후보 #71은 짧은 발췌에서 enhancement 2·documentation 1(2/3)이라 기권이 되어 뺐다. #181은 원문에서 문서 근거가 더 드러나는 문장을 골라 3/4로 맞췄다. diff --git a/internal/architecture/ddd_responsibility_test.go b/internal/architecture/ddd_responsibility_test.go index eeb6fcdb3..11f8de41b 100644 --- a/internal/architecture/ddd_responsibility_test.go +++ b/internal/architecture/ddd_responsibility_test.go @@ -743,7 +743,7 @@ func dddArtifactOwner(path string) (dddArtifactEntry, bool) { entry.Task = "T11" case "scripts/sync-glab-mcp.sh", "scripts/release-build-matrix.sh", "scripts/release-repro-smoke.sh": entry.Task = "T12" - case "scripts/measure_efficiency.py", "scripts/measure_efficiency_test.py", "scripts/autoresearch/doc_injection_eval.py", "scripts/autoresearch/doc_injection_judge.py", "scripts/autoresearch/procedure_review_eval.py": + case "scripts/measure_efficiency.py", "scripts/measure_efficiency_test.py", "scripts/autoresearch/doc_injection_eval.py", "scripts/autoresearch/doc_injection_judge.py", "scripts/autoresearch/label_score_eval.py", "scripts/autoresearch/procedure_review_eval.py": entry.Task = "T17" case "scripts/python_suite_runner.py", "scripts/python_suite_runner_test.py": entry.Task = "T18" diff --git a/internal/architecture/testdata/ddd_responsibility_inventory.json b/internal/architecture/testdata/ddd_responsibility_inventory.json index 600183ed1..a42a86cde 100644 --- a/internal/architecture/testdata/ddd_responsibility_inventory.json +++ b/internal/architecture/testdata/ddd_responsibility_inventory.json @@ -15904,9 +15904,12 @@ "owner": "domain", "task": "T20", "symbols": [ - "func issueOpsRemoteLabelHeuristic", + "func issueOpsRemoteKeywordMatches", + "func issueOpsRemoteLabelEvidence", + "func issueOpsRemoteLabelEvidenceScore", "func issueOpsRemoteOverlap", - "func issueOpsRemoteTokens" + "func issueOpsRemoteTokens", + "type issueOpsRemoteTypeEvidence" ] }, { @@ -18289,6 +18292,11 @@ "owner": "execution-script", "task": "T17" }, + { + "path": "scripts/autoresearch/label_score_eval.py", + "owner": "execution-script", + "task": "T17" + }, { "path": "scripts/autoresearch/procedure_review_eval.py", "owner": "execution-script", diff --git a/internal/domain/issueopsremote/issueops_remote_score.go b/internal/domain/issueopsremote/issueops_remote_score.go index f63130c02..0693169c8 100644 --- a/internal/domain/issueopsremote/issueops_remote_score.go +++ b/internal/domain/issueopsremote/issueops_remote_score.go @@ -26,6 +26,7 @@ func ScoreIssueOpsRemoteCandidates(req IssueOpsRemoteScoringRequest) (IssueOpsRe } issueTokens := issueOpsRemoteTokens(issueText) result := normalizeIssueOpsRemoteScoringResult(IssueOpsRemoteScoringResult{OK: true, Provider: provider, Threshold: threshold}) + typeEvidence := issueOpsRemoteLabelEvidence(issueTokens) for _, candidate := range req.IssueCandidates { item := scoreIssueOpsRemoteIssue(provider, threshold, issueTokens, candidate) if item.Selected { @@ -35,7 +36,7 @@ func ScoreIssueOpsRemoteCandidates(req IssueOpsRemoteScoringRequest) (IssueOpsRe } } for _, candidate := range req.LabelCandidates { - item := scoreIssueOpsRemoteLabel(provider, threshold, issueTokens, candidate) + item := scoreIssueOpsRemoteLabel(provider, threshold, issueTokens, typeEvidence, candidate) if item.Selected { result.SelectedLabels = append(result.SelectedLabels, item) } else { @@ -117,17 +118,19 @@ func scoreIssueOpsRemoteIssue(provider string, threshold float64, issueTokens ma return item } -func scoreIssueOpsRemoteLabel(provider string, threshold float64, issueTokens map[string]bool, candidate IssueOpsRemoteLabelCandidate) IssueOpsRemoteScoredItem { +func scoreIssueOpsRemoteLabel(provider string, threshold float64, issueTokens map[string]bool, typeEvidence issueOpsRemoteTypeEvidence, candidate IssueOpsRemoteLabelCandidate) IssueOpsRemoteScoredItem { score := scorePtr(candidate.Score) evidence := []string{} if candidate.Score != nil { evidence = append(evidence, "explicit score provided") + } else if typeScore, matched, ok := issueOpsRemoteLabelEvidenceScore(typeEvidence, candidate); ok { + score = clampScore(typeScore) + evidence = append(evidence, fmt.Sprintf("type_evidence=%d/%d", len(matched), typeEvidence.total), "matched_tokens="+strings.Join(matched, ",")) } else { labelText := strings.TrimSpace(candidate.Name + "\n" + candidate.Description + "\n" + strings.Join(candidate.Aliases, " ")) overlap := issueOpsRemoteOverlap(issueTokens, issueOpsRemoteTokens(labelText)) - heuristic := issueOpsRemoteLabelHeuristic(issueTokens, candidate) - score = clampScore(0.65*overlap + 0.35*heuristic) - evidence = append(evidence, fmt.Sprintf("token_overlap=%.2f", overlap), fmt.Sprintf("type_heuristic=%.2f", heuristic)) + score = clampScore(0.65 * overlap) + evidence = append(evidence, fmt.Sprintf("token_overlap=%.2f", overlap), "type_heuristic=0.00") } selected := score >= threshold item := IssueOpsRemoteScoredItem{ diff --git a/internal/domain/issueopsremote/issueops_remote_score_label_test.go b/internal/domain/issueopsremote/issueops_remote_score_label_test.go new file mode 100644 index 000000000..3cb79fdf4 --- /dev/null +++ b/internal/domain/issueopsremote/issueops_remote_score_label_test.go @@ -0,0 +1,85 @@ +package remote + +import ( + "encoding/json" + "math" + "os" + "path/filepath" + "reflect" + "testing" +) + +// githubDefaultLabelCandidates는 gh label list의 기본 라벨 설명을 그대로 쓴다. +func githubDefaultLabelCandidates() []IssueOpsRemoteLabelCandidate { + return []IssueOpsRemoteLabelCandidate{ + {Name: "bug", Description: "Something isn't working"}, + {Name: "enhancement", Description: "New feature or request"}, + {Name: "documentation", Description: "Improvements or additions to documentation"}, + {Name: "question", Description: "Further information is requested"}, + } +} + +func TestScoreIssueOpsRemoteLabelQualityTable(t *testing.T) { + type qualityCase struct { + Issue int `json:"issue"` + Note string `json:"note"` + Title string `json:"title"` + Body string `json:"body"` + WantSelected []string `json:"want_selected"` + } + raw, err := os.ReadFile(filepath.Join("testdata", "label_quality_cases.json")) + if err != nil { + t.Fatal(err) + } + var cases []qualityCase + if err := json.Unmarshal(raw, &cases); err != nil { + t.Fatal(err) + } + if len(cases) == 0 { + t.Fatal("quality table is empty") + } + for _, tc := range cases { + if tc.Issue >= 232 { + t.Fatalf("case #%d is outside the development set (numbers below 232)", tc.Issue) + } + res, err := ScoreIssueOpsRemoteCandidates(IssueOpsRemoteScoringRequest{ + Issue: IssueOpsRemoteArtifact{Title: tc.Title, Body: tc.Body}, + LabelCandidates: githubDefaultLabelCandidates(), + }) + if err != nil { + t.Fatalf("#%d: %v", tc.Issue, err) + } + got := []string{} + for _, item := range res.SelectedLabels { + got = append(got, item.Name) + } + if !reflect.DeepEqual(got, tc.WantSelected) { + t.Errorf("#%d selected labels = %v, want %v (%s): %+v", tc.Issue, got, tc.WantSelected, tc.Note, res.SelectedLabels) + } + } +} + +func TestScoreIssueOpsRemoteLabelWithoutKeywordRuleKeepsOverlapScore(t *testing.T) { + res, err := ScoreIssueOpsRemoteCandidates(IssueOpsRemoteScoringRequest{ + Issue: IssueOpsRemoteArtifact{Title: "Further information is requested about login", Body: "오류가 재현되지 않는다 오류 결함 버그"}, + LabelCandidates: githubDefaultLabelCandidates(), + }) + if err != nil { + t.Fatal(err) + } + var question *IssueOpsRemoteScoredItem + for i := range res.RejectedLabels { + if res.RejectedLabels[i].Name == "question" { + question = &res.RejectedLabels[i] + } + } + if question == nil { + t.Fatalf("question label was not rejected: selected=%+v rejected=%+v", res.SelectedLabels, res.RejectedLabels) + } + if want := []string{"token_overlap=0.75", "type_heuristic=0.00"}; !reflect.DeepEqual(question.Evidence, want) { + t.Fatalf("question evidence = %v, want %v", question.Evidence, want) + } + if math.Abs(question.Score-0.65*0.75) > 1e-9 { + t.Fatalf("question score = %v, want 0.65*overlap", question.Score) + } +} diff --git a/internal/domain/issueopsremote/issueops_remote_score_tokens.go b/internal/domain/issueopsremote/issueops_remote_score_tokens.go index 123f2e378..bdeb69b50 100644 --- a/internal/domain/issueopsremote/issueops_remote_score_tokens.go +++ b/internal/domain/issueopsremote/issueops_remote_score_tokens.go @@ -1,8 +1,10 @@ package remote import ( + "sort" "strings" "unicode" + "unicode/utf8" ) func issueOpsRemoteTokens(text string) map[string]bool { @@ -37,26 +39,68 @@ func issueOpsRemoteOverlap(left, right map[string]bool) float64 { return float64(intersect) / float64(min(len(left), len(right))) } -// issueOpsRemoteLabelKeywords는 라벨 이름별 휴리스틱 키워드와 일치 시 점수를 정의한다. -var issueOpsRemoteLabelKeywords = map[string]struct { - tokens []string - score float64 -}{ - "enhancement": {[]string{"feature", "request", "개선", "추가", "지원"}, 1}, - "bug": {[]string{"bug", "defect", "failure", "오류", "결함"}, 1}, - "documentation": {[]string{"docs", "documentation", "문서", "skill", "prompt"}, 0.75}, +// issueOpsRemoteLabelKeywords는 키워드 규칙이 있는 라벨 이름별 유형 근거 키워드다. +// 한글 키워드는 토큰의 접두와 맞으면(조사·어미가 붙은 토큰 포함), 영어 키워드는 토큰 전체가 같을 때 맞는다. +var issueOpsRemoteLabelKeywords = map[string][]string{ + "bug": { + "bug", "defect", "failure", "fail", "failed", "fails", "error", "crash", "regression", "broken", + "오류", "결함", "버그", "실패", "회귀", "깨진", "깨지", "잘못", "누락", "오동작", "재현", "레이스", "교착", "손상", "유실", + "막힌", "막혀", "거부되", "고친다", "수정한다", + }, + "enhancement": { + "feature", "request", "support", "add", "enhancement", + "개선", "추가", "지원", "도입", "확장", "새로운", "신규", "기능", "강화", + }, + "documentation": {"docs", "documentation", "readme", "문서"}, } -func issueOpsRemoteLabelHeuristic(issueTokens map[string]bool, candidate IssueOpsRemoteLabelCandidate) float64 { - name := strings.ToLower(strings.TrimSpace(candidate.Name)) - rule, ok := issueOpsRemoteLabelKeywords[name] - if !ok { - return 0 +// issueOpsRemoteMinLabelEvidence는 규칙 라벨을 고르기 위한 전체 유형 근거 토큰의 최소 개수다. +// 근거 하나로 고른 경우의 정답률이 둘 이상일 때보다 낮아 그 미만이면 기권한다. +const issueOpsRemoteMinLabelEvidence = 2 + +// issueOpsRemoteTypeEvidence는 이슈 토큰에서 규칙 라벨별로 센 유형 근거다. +type issueOpsRemoteTypeEvidence struct { + matched map[string][]string // 규칙 라벨 이름 -> 맞은 토큰(정렬) + total int // 모든 규칙 라벨의 맞은 토큰 수 합 +} + +// issueOpsRemoteKeywordMatches는 토큰이 키워드에 맞는지 판정한다. +func issueOpsRemoteKeywordMatches(token, keyword string) bool { + if keyword[0] < utf8.RuneSelf { + return token == keyword } - for _, token := range rule.tokens { - if issueTokens[token] { - return rule.score + return strings.HasPrefix(token, keyword) +} + +// issueOpsRemoteLabelEvidence는 이슈 토큰마다 규칙 라벨별로 최대 한 번씩 근거를 센다. +func issueOpsRemoteLabelEvidence(issueTokens map[string]bool) issueOpsRemoteTypeEvidence { + evidence := issueOpsRemoteTypeEvidence{matched: map[string][]string{}} + for label, keywords := range issueOpsRemoteLabelKeywords { + var matched []string + for token := range issueTokens { + for _, keyword := range keywords { + if issueOpsRemoteKeywordMatches(token, keyword) { + matched = append(matched, token) + break + } + } } + sort.Strings(matched) + evidence.matched[label] = matched + evidence.total += len(matched) + } + return evidence +} + +// issueOpsRemoteLabelEvidenceScore는 규칙 라벨의 점수를 돌려준다. 이름에 규칙이 없으면 ok가 false다. +// 전체 근거가 최소 개수 미만이면 0이다. +func issueOpsRemoteLabelEvidenceScore(evidence issueOpsRemoteTypeEvidence, candidate IssueOpsRemoteLabelCandidate) (score float64, matched []string, ok bool) { + matched, ok = evidence.matched[strings.ToLower(strings.TrimSpace(candidate.Name))] + if !ok { + return 0, nil, false + } + if evidence.total < issueOpsRemoteMinLabelEvidence { + return 0, matched, true } - return 0 + return float64(len(matched)) / float64(evidence.total), matched, true } diff --git a/internal/domain/issueopsremote/issueops_remote_score_tokens_test.go b/internal/domain/issueopsremote/issueops_remote_score_tokens_test.go index 09149c140..08ab34b6c 100644 --- a/internal/domain/issueopsremote/issueops_remote_score_tokens_test.go +++ b/internal/domain/issueopsremote/issueops_remote_score_tokens_test.go @@ -1,6 +1,9 @@ package remote -import "testing" +import ( + "reflect" + "testing" +) func TestIssueOpsRemoteOverlapUsesSmallerTokenSet(t *testing.T) { left := map[string]bool{"refactor": true, "coverage": true, "issueops": true} @@ -20,44 +23,60 @@ func TestIssueOpsRemoteOverlapRejectsEmptyInputs(t *testing.T) { } } -func TestIssueOpsRemoteLabelHeuristicMatchesKnownLabels(t *testing.T) { +func TestIssueOpsRemoteKeywordMatchesKoreanPrefixAndEnglishExact(t *testing.T) { tests := []struct { - name string - tokens map[string]bool - label IssueOpsRemoteLabelCandidate - want float64 + token, keyword string + want bool }{ - { - name: "enhancement", - tokens: map[string]bool{"feature": true}, - label: IssueOpsRemoteLabelCandidate{Name: " Enhancement "}, - want: 1, - }, - { - name: "bug", - tokens: map[string]bool{"failure": true}, - label: IssueOpsRemoteLabelCandidate{Name: "BUG"}, - want: 1, - }, - { - name: "documentation", - tokens: map[string]bool{"docs": true}, - label: IssueOpsRemoteLabelCandidate{Name: "documentation"}, - want: 0.75, - }, - { - name: "unknown", - tokens: map[string]bool{"coverage": true}, - label: IssueOpsRemoteLabelCandidate{Name: "refactor"}, - want: 0, - }, + {"오류가", "오류", true}, + {"재현되지", "재현", true}, + {"오류", "오류", true}, + {"add", "add", true}, + {"address", "add", false}, + {"adds", "add", false}, + {"여기오류", "오류", false}, + {"오", "오류", false}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := issueOpsRemoteLabelHeuristic(tt.tokens, tt.label); got != tt.want { - t.Fatalf("issueOpsRemoteLabelHeuristic() = %v, want %v", got, tt.want) - } - }) + if got := issueOpsRemoteKeywordMatches(tt.token, tt.keyword); got != tt.want { + t.Errorf("issueOpsRemoteKeywordMatches(%q, %q) = %v, want %v", tt.token, tt.keyword, got, tt.want) + } + } +} + +func TestIssueOpsRemoteLabelEvidenceCountsEachTokenOncePerRule(t *testing.T) { + tokens := issueOpsRemoteTokens("오류가 오류를 failure add address 문서 readme") + evidence := issueOpsRemoteLabelEvidence(tokens) + + want := map[string][]string{ + "bug": {"failure", "오류가", "오류를"}, + "enhancement": {"add"}, + "documentation": {"readme", "문서"}, + } + for label, wantTokens := range want { + if !reflect.DeepEqual(evidence.matched[label], wantTokens) { + t.Errorf("matched[%s] = %v, want %v", label, evidence.matched[label], wantTokens) + } + } + if evidence.total != 6 { + t.Errorf("total = %d, want 6", evidence.total) + } +} + +func TestIssueOpsRemoteLabelEvidenceScoreAbstainsBelowMinimumEvidence(t *testing.T) { + bug := IssueOpsRemoteLabelCandidate{Name: " BUG "} + one := issueOpsRemoteLabelEvidence(issueOpsRemoteTokens("회귀")) + score, matched, ok := issueOpsRemoteLabelEvidenceScore(one, bug) + if !ok || score != 0 || !reflect.DeepEqual(matched, []string{"회귀"}) { + t.Fatalf("one evidence: score=%v matched=%v ok=%v, want 0 [회귀] true", score, matched, ok) + } + + two := issueOpsRemoteLabelEvidence(issueOpsRemoteTokens("회귀 failure")) + if score, _, _ := issueOpsRemoteLabelEvidenceScore(two, bug); score != 1 { + t.Fatalf("two evidence: score=%v, want 1", score) + } + + if _, _, ok := issueOpsRemoteLabelEvidenceScore(two, IssueOpsRemoteLabelCandidate{Name: "question"}); ok { + t.Fatal("label without a keyword rule must not use the evidence score") } } diff --git a/internal/domain/issueopsremote/testdata/label_quality_cases.json b/internal/domain/issueopsremote/testdata/label_quality_cases.json new file mode 100644 index 000000000..564569a15 --- /dev/null +++ b/internal/domain/issueopsremote/testdata/label_quality_cases.json @@ -0,0 +1,51 @@ +[ + { + "issue": 210, + "note": "조사·어미가 붙은 한국어 결함 어휘(실패를, 실패합니다, 재현하는, 회귀)가 bug 근거 4개로 모인다", + "title": "fix(daemon): 레거시 프로세스 시작 시간 비교를 타임존 독립적으로 정규화", + "body": "## 문제\n`TestDaemonProcessIdentityMatchesLegacyLocalizedStartTime`는 KST 환경에서는 통과하지만 Ubuntu/GitHub Actions의 UTC 러너에서는 실패합니다.\n\n## 원인\n`canonicalProcessStartTime`가 레거시 한국어 receipt를 `time.Local`로 파싱합니다. 그래서 러너 타임존에 따라 같은 프로세스의 시작 시간이 다르게 해석됩니다.\n\n## 수용 기준\n- UTC에서 실패를 재현하는 회귀 테스트를 추가한다.", + "want_selected": ["bug"] + }, + { + "issue": 41, + "note": "근거가 두 개뿐이어도 모두 bug면 선택한다", + "title": "no-change handoff의 worker_done 빈 변경 목록 projection 회귀 수정", + "body": "## 문제\n\nno-change handoff finish가 durable state에는 성공하지만 Orca worker_done projection이 빈 changed_files를 invalid로 거부해 coordinator 자동 알림이 실패한다.", + "want_selected": ["bug"] + }, + { + "issue": 81, + "note": "신규·추가·확장이 enhancement 근거 3개로 모인다", + "title": "[p] C3: 부모 이슈 close와 completion 섹션 provider 확장", + "body": "## 범위\n- port.IssueProvider에 CloseIssue 추가(gh issue close --reason completed)\n- 신규 CLI: issueops remote reflect-completion / close-issue (preview→confirm, 한국어 게이트)", + "want_selected": ["enhancement"] + }, + { + "issue": 181, + "note": "문서, 문서가, 문서를이 documentation 근거 3개이고 bug 근거는 결함 하나라 3/4로 선택한다", + "title": "문서: orca 발행 경로와 가드·어휘 규율을 이 세션 결과로 갱신한다", + "body": "## 문제\n\n이 세션에서 결함 8건을 머지했는데 `.agent-harness` 공용 문서가 그 결과를 반영하지 않는다.\n\n문서를 따라 실행하면 이 세션에서 실측한 발행 불가 상태로 다시 들어간다.", + "want_selected": ["documentation"] + }, + { + "issue": 109, + "note": "enhancement 2와 documentation 2가 같아 어느 라벨도 0.7에 닿지 않아 기권한다", + "title": "contract golden의 docs 계수를 placeholder로 정규화: 문서 추가마다 반복되는 수동 동기화 함정 제거", + "body": "## 문제\n\n`response_contracts.golden.json`이 `docs_count`의 절대값을 스냅샷으로 고정해, md가 추적될 때마다 golden 수동 동기화가 필요하다.\n\n## 수용 기준\n\n- golden 내 docs_count가 placeholder로 고정되고, 문서 추가에도 golden 테스트가 green이다.", + "want_selected": [] + }, + { + "issue": 39, + "note": "근거가 회귀 하나뿐이라 고르면 오답 위험이 커서 기권한다", + "title": "[p] no-change finish hook parity 회귀 수정", + "body": "## 작업 목표\n\nworker가 허용된 finish completion preset을 Codex PreToolUse fence에서도 실행할 수 있게 parser와 allowlist를 CLI 계약과 동기화한다.", + "want_selected": [] + }, + { + "issue": 158, + "note": "근거가 add 하나뿐이라 기권한다(원래 라벨은 bug)", + "title": "decision add가 lease active 중 실행되지 않아 구현 중 결정을 기록할 수 없다", + "body": "## 문제\n\nallowlist에 `decision add`가 없다. lease가 active인 동안 `issueops decision add`가 거부된다.", + "want_selected": [] + } +] diff --git a/scripts/autoresearch/label_score_eval.py b/scripts/autoresearch/label_score_eval.py new file mode 100755 index 000000000..bf67594b3 --- /dev/null +++ b/scripts/autoresearch/label_score_eval.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Measure `issueops remote score --judge none` label selection on this repository's issues. + +Reads every issue of the current GitHub repository (`gh issue list`, read only), +keeps the issues that carry exactly one label inside --min-number/--max-number, +strips the noise a reader would not see when the label is chosen (leading +`[tag]` title prefixes, `#123` references, GitHub issue/PR URLs), and scores each +one with the repository's labels (`gh label list`) as candidates at the default +threshold. + +Reported on one JSON line: + n_total issues in range with exactly one label + n_selected issues where the scorer selected at least one label + n_correct selected issues whose selected labels are exactly the GitHub label + accuracy n_correct / n_selected (0 when nothing was selected) + +--check prints the JSON line and then a final `PASS` line when n_selected and +accuracy meet --min-selected and --min-accuracy; otherwise a final `FAIL` line +and exit code 1. Nothing is written to the repository or to GitHub; request +files live in a temporary directory that is removed afterwards. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +TITLE_TAG_RE = re.compile(r"^\s*(\[[^\]]+\]\s*)+") +ISSUE_URL_RE = re.compile(r"https?://github\.com/\S+/(issues|pull)/\d+") +ISSUE_REF_RE = re.compile(r"(? list[dict]: + proc = subprocess.run(["gh", *args], capture_output=True, text=True) + if proc.returncode != 0: + raise SystemExit(f"gh {' '.join(args)} failed: {proc.stderr.strip()}") + return json.loads(proc.stdout) + + +def clean_issue(issue: dict) -> tuple[str, str]: + title = ISSUE_REF_RE.sub("#?", TITLE_TAG_RE.sub("", issue["title"])) + body = ISSUE_REF_RE.sub("#?", ISSUE_URL_RE.sub("[링크]", issue.get("body") or "")) + return title, body + + +def score_labels(binary: str, workdir: Path, number: int, title: str, body: str, labels: list[dict]) -> list[str]: + request = { + "issue": {"title": title, "body": body}, + "label_candidates": [{"name": l["name"], "description": l.get("description", "")} for l in labels], + } + path = workdir / f"{number}.json" + path.write_text(json.dumps(request, ensure_ascii=False), encoding="utf-8") + proc = subprocess.run( + [binary, "remote", "score", "--judge", "none", "--json", "--input", str(path)], + capture_output=True, + text=True, + ) + try: + result = json.loads(proc.stdout) + except json.JSONDecodeError: + raise SystemExit(f"remote score #{number} produced no JSON (exit {proc.returncode}): {proc.stderr.strip()}") + return sorted(item["name"] for item in result.get("selected_labels") or []) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--binary", default="bin/issueops", help="issueops binary to run") + parser.add_argument("--min-number", type=int, default=0) + parser.add_argument("--max-number", type=int, default=10**9) + parser.add_argument("--check", action="store_true", help="print PASS/FAIL against the thresholds below") + parser.add_argument("--min-selected", type=int, default=50) + parser.add_argument("--min-accuracy", type=float, default=0.85) + args = parser.parse_args() + + labels = run_gh("label", "list", "--limit", "200", "--json", "name,description") + issues = run_gh("issue", "list", "--state", "all", "--limit", "1000", "--json", "number,title,body,labels") + targets = [ + i + for i in issues + if args.min_number <= i["number"] <= args.max_number and len(i["labels"]) == 1 + ] + + n_selected = n_correct = 0 + with tempfile.TemporaryDirectory(prefix="label-score-eval-") as tmp: + workdir = Path(tmp) + for issue in sorted(targets, key=lambda i: i["number"]): + title, body = clean_issue(issue) + selected = score_labels(args.binary, workdir, issue["number"], title, body, labels) + if selected: + n_selected += 1 + if selected == [issue["labels"][0]["name"]]: + n_correct += 1 + + accuracy = n_correct / n_selected if n_selected else 0.0 + print( + json.dumps( + { + "min_number": args.min_number, + "max_number": args.max_number, + "n_total": len(targets), + "n_selected": n_selected, + "n_correct": n_correct, + "accuracy": round(accuracy, 4), + }, + ensure_ascii=False, + ) + ) + if args.check: + ok = n_selected >= args.min_selected and accuracy >= args.min_accuracy + print("PASS" if ok else "FAIL") + return 0 if ok else 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())