feat(qa): recital detection, review split, duplicate comparison
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 42s
CI/CD / test-python-backend-compliance (push) Successful in 34s
CI/CD / test-python-document-crawler (push) Successful in 21s
CI/CD / test-python-dsms-gateway (push) Successful in 20s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 42s
CI/CD / test-python-backend-compliance (push) Successful in 34s
CI/CD / test-python-document-crawler (push) Successful in 21s
CI/CD / test-python-dsms-gateway (push) Successful in 20s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped
Add _detect_recital() to QA pipeline — flags controls where source_original_text contains Erwägungsgrund markers instead of article text (28% of controls with source text affected). - Recital detection via regex + phrase matching in QA validation - 10 new tests (TestRecitalDetection), 81 total - ReviewCompare component for side-by-side duplicate comparison - Review mode split: Duplikat-Verdacht vs Rule-3-ohne-Anchor tabs - MkDocs: recital detection documentation - Detection script for bulk analysis (scripts/find_recital_controls.py) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
} from './components/helpers'
|
||||
import { ControlForm } from './components/ControlForm'
|
||||
import { ControlDetail } from './components/ControlDetail'
|
||||
import { ReviewCompare } from './components/ReviewCompare'
|
||||
import { GeneratorModal } from './components/GeneratorModal'
|
||||
|
||||
// =============================================================================
|
||||
@@ -71,6 +72,9 @@ export default function ControlLibraryPage() {
|
||||
const [reviewIndex, setReviewIndex] = useState(0)
|
||||
const [reviewItems, setReviewItems] = useState<CanonicalControl[]>([])
|
||||
const [reviewCount, setReviewCount] = useState(0)
|
||||
const [reviewTab, setReviewTab] = useState<'duplicates' | 'rule3'>('duplicates')
|
||||
const [reviewDuplicates, setReviewDuplicates] = useState<CanonicalControl[]>([])
|
||||
const [reviewRule3, setReviewRule3] = useState<CanonicalControl[]>([])
|
||||
|
||||
// Debounce search
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -303,20 +307,47 @@ export default function ControlLibraryPage() {
|
||||
const enterReviewMode = async () => {
|
||||
// Load review items from backend
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=200`)
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=1000`)
|
||||
if (res.ok) {
|
||||
const items = await res.json()
|
||||
const items: CanonicalControl[] = await res.json()
|
||||
if (items.length > 0) {
|
||||
setReviewItems(items)
|
||||
// Split into duplicate suspects vs rule 3 without anchor
|
||||
const dupes = items.filter(c =>
|
||||
c.generation_metadata?.similar_controls &&
|
||||
Array.isArray(c.generation_metadata.similar_controls) &&
|
||||
(c.generation_metadata.similar_controls as unknown[]).length > 0
|
||||
)
|
||||
const rule3 = items.filter(c =>
|
||||
!c.generation_metadata?.similar_controls ||
|
||||
!Array.isArray(c.generation_metadata.similar_controls) ||
|
||||
(c.generation_metadata.similar_controls as unknown[]).length === 0
|
||||
)
|
||||
setReviewDuplicates(dupes)
|
||||
setReviewRule3(rule3)
|
||||
// Start with duplicates tab if any, otherwise rule3
|
||||
const startTab = dupes.length > 0 ? 'duplicates' : 'rule3'
|
||||
const startItems = startTab === 'duplicates' ? dupes : rule3
|
||||
setReviewTab(startTab)
|
||||
setReviewItems(startItems)
|
||||
setReviewMode(true)
|
||||
setReviewIndex(0)
|
||||
setSelectedControl(items[0])
|
||||
setSelectedControl(startItems[0])
|
||||
setMode('detail')
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const switchReviewTab = (tab: 'duplicates' | 'rule3') => {
|
||||
const items = tab === 'duplicates' ? reviewDuplicates : reviewRule3
|
||||
setReviewTab(tab)
|
||||
setReviewItems(items)
|
||||
setReviewIndex(0)
|
||||
if (items.length > 0) {
|
||||
setSelectedControl(items[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Loading
|
||||
if (loading && controls.length === 0) {
|
||||
return (
|
||||
@@ -363,28 +394,89 @@ export default function ControlLibraryPage() {
|
||||
|
||||
// DETAIL MODE
|
||||
if (mode === 'detail' && selectedControl) {
|
||||
const isDuplicateReview = reviewMode && reviewTab === 'duplicates'
|
||||
|
||||
// Review tab bar (shown above the detail/compare view in review mode)
|
||||
const reviewTabBar = reviewMode ? (
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-2 flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => switchReviewTab('duplicates')}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg font-medium ${
|
||||
reviewTab === 'duplicates'
|
||||
? 'bg-amber-100 text-amber-800 border border-amber-300'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Duplikat-Verdacht ({reviewDuplicates.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchReviewTab('rule3')}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg font-medium ${
|
||||
reviewTab === 'rule3'
|
||||
? 'bg-purple-100 text-purple-800 border border-purple-300'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Rule 3 ohne Anchor ({reviewRule3.length})
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
if (isDuplicateReview) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{reviewTabBar}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ReviewCompare
|
||||
ctrl={selectedControl}
|
||||
onBack={() => { setMode('list'); setSelectedControl(null); setReviewMode(false) }}
|
||||
onReview={handleReview}
|
||||
onEdit={() => setMode('edit')}
|
||||
reviewIndex={reviewIndex}
|
||||
reviewTotal={reviewItems.length}
|
||||
onReviewPrev={() => {
|
||||
const idx = Math.max(0, reviewIndex - 1)
|
||||
setReviewIndex(idx)
|
||||
setSelectedControl(reviewItems[idx])
|
||||
}}
|
||||
onReviewNext={() => {
|
||||
const idx = Math.min(reviewItems.length - 1, reviewIndex + 1)
|
||||
setReviewIndex(idx)
|
||||
setSelectedControl(reviewItems[idx])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlDetail
|
||||
ctrl={selectedControl}
|
||||
onBack={() => { setMode('list'); setSelectedControl(null); setReviewMode(false) }}
|
||||
onEdit={() => setMode('edit')}
|
||||
onDelete={handleDelete}
|
||||
onReview={handleReview}
|
||||
onRefresh={fullReload}
|
||||
reviewMode={reviewMode}
|
||||
reviewIndex={reviewIndex}
|
||||
reviewTotal={reviewItems.length}
|
||||
onReviewPrev={() => {
|
||||
const idx = Math.max(0, reviewIndex - 1)
|
||||
setReviewIndex(idx)
|
||||
setSelectedControl(reviewItems[idx])
|
||||
}}
|
||||
onReviewNext={() => {
|
||||
const idx = Math.min(reviewItems.length - 1, reviewIndex + 1)
|
||||
setReviewIndex(idx)
|
||||
setSelectedControl(reviewItems[idx])
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col h-full">
|
||||
{reviewTabBar}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ControlDetail
|
||||
ctrl={selectedControl}
|
||||
onBack={() => { setMode('list'); setSelectedControl(null); setReviewMode(false) }}
|
||||
onEdit={() => setMode('edit')}
|
||||
onDelete={handleDelete}
|
||||
onReview={handleReview}
|
||||
onRefresh={fullReload}
|
||||
reviewMode={reviewMode}
|
||||
reviewIndex={reviewIndex}
|
||||
reviewTotal={reviewItems.length}
|
||||
onReviewPrev={() => {
|
||||
const idx = Math.max(0, reviewIndex - 1)
|
||||
setReviewIndex(idx)
|
||||
setSelectedControl(reviewItems[idx])
|
||||
}}
|
||||
onReviewNext={() => {
|
||||
const idx = Math.min(reviewItems.length - 1, reviewIndex + 1)
|
||||
setReviewIndex(idx)
|
||||
setSelectedControl(reviewItems[idx])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user