feat: initial project setup

- Add .NET 8 backend with Clean Architecture
- Add React + Vite + TypeScript frontend
- Implement authentication with JWT
- Implement Azure Blob Storage client
- Implement OCR integration
- Implement supplier matching service
- Implement voucher generation
- Implement Fortnox provider
- Add unit and integration tests
- Add Docker Compose configuration
This commit is contained in:
Invoice Master
2026-02-04 20:14:34 +01:00
commit 05ea67144f
250 changed files with 50402 additions and 0 deletions

93
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,93 @@
# Invoice Master POC v2
Swedish Invoice Field Extraction System - YOLOv11 + PaddleOCR 从瑞典 PDF 发票中提取结构化数据。
## Tech Stack
| Component | Technology |
|-----------|------------|
| Object Detection | YOLOv11 (Ultralytics) |
| OCR Engine | PaddleOCR v5 (PP-OCRv5) |
| PDF Processing | PyMuPDF (fitz) |
| Database | PostgreSQL + psycopg2 |
| Web Framework | FastAPI + Uvicorn |
| Deep Learning | PyTorch + CUDA 12.x |
## WSL Environment (REQUIRED)
**Prefix ALL commands with:**
```bash
wsl bash -c "source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && <command>"
```
**NEVER run Python commands directly in Windows PowerShell/CMD.**
## Project-Specific Rules
- Python 3.11+ with type hints
- No print() in production - use logging
- Run tests: `pytest --cov=src`
## Critical Rules
### Code Organization
- Many small files over few large files
- High cohesion, low coupling
- 200-400 lines typical, 800 max per file
- Organize by feature/domain, not by type
### Code Style
- No emojis in code, comments, or documentation
- Immutability always - never mutate objects or arrays
- No console.log in production code
- Proper error handling with try/catch
- Input validation with Zod or similar
### Testing
- TDD: Write tests first
- 80% minimum coverage
- Unit tests for utilities
- Integration tests for APIs
- E2E tests for critical flows
### Security
- No hardcoded secrets
- Environment variables for sensitive data
- Validate all user inputs
- Parameterized queries only
- CSRF protection enabled
## Environment Variables
```bash
# Required
DB_PASSWORD=
# Optional (with defaults)
DB_HOST=192.168.68.31
DB_PORT=5432
DB_NAME=docmaster
DB_USER=docmaster
MODEL_PATH=runs/train/invoice_fields/weights/best.pt
CONFIDENCE_THRESHOLD=0.5
SERVER_HOST=0.0.0.0
SERVER_PORT=8000
```
## Available Commands
- `/tdd` - Test-driven development workflow
- `/plan` - Create implementation plan
- `/code-review` - Review code quality
- `/build-fix` - Fix build errors
## Git Workflow
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`
- Never commit to main directly
- PRs require review
- All tests must pass before merge

View File

@@ -0,0 +1,22 @@
# Build and Fix
Incrementally fix Python errors and test failures.
## Workflow
1. Run check: `mypy src/ --ignore-missing-imports` or `pytest -x --tb=short`
2. Parse errors, group by file, sort by severity (ImportError > TypeError > other)
3. For each error:
- Show context (5 lines)
- Explain and propose fix
- Apply fix
- Re-run test for that file
- Verify resolved
4. Stop if: fix introduces new errors, same error after 3 attempts, or user pauses
5. Show summary: fixed / remaining / new errors
## Rules
- Fix ONE error at a time
- Re-run tests after each fix
- Never batch multiple unrelated fixes

View File

@@ -0,0 +1,74 @@
# Checkpoint Command
Create or verify a checkpoint in your workflow.
## Usage
`/checkpoint [create|verify|list] [name]`
## Create Checkpoint
When creating a checkpoint:
1. Run `/verify quick` to ensure current state is clean
2. Create a git stash or commit with checkpoint name
3. Log checkpoint to `.claude/checkpoints.log`:
```bash
echo "$(date +%Y-%m-%d-%H:%M) | $CHECKPOINT_NAME | $(git rev-parse --short HEAD)" >> .claude/checkpoints.log
```
4. Report checkpoint created
## Verify Checkpoint
When verifying against a checkpoint:
1. Read checkpoint from log
2. Compare current state to checkpoint:
- Files added since checkpoint
- Files modified since checkpoint
- Test pass rate now vs then
- Coverage now vs then
3. Report:
```
CHECKPOINT COMPARISON: $NAME
============================
Files changed: X
Tests: +Y passed / -Z failed
Coverage: +X% / -Y%
Build: [PASS/FAIL]
```
## List Checkpoints
Show all checkpoints with:
- Name
- Timestamp
- Git SHA
- Status (current, behind, ahead)
## Workflow
Typical checkpoint flow:
```
[Start] --> /checkpoint create "feature-start"
|
[Implement] --> /checkpoint create "core-done"
|
[Test] --> /checkpoint verify "core-done"
|
[Refactor] --> /checkpoint create "refactor-done"
|
[PR] --> /checkpoint verify "feature-start"
```
## Arguments
$ARGUMENTS:
- `create <name>` - Create named checkpoint
- `verify <name>` - Verify against named checkpoint
- `list` - Show all checkpoints
- `clear` - Remove old checkpoints (keeps last 5)

View File

@@ -0,0 +1,46 @@
# Code Review
Security and quality review of uncommitted changes.
## Workflow
1. Get changed files: `git diff --name-only HEAD` and `git diff --staged --name-only`
2. Review each file for issues (see checklist below)
3. Run automated checks: `mypy src/`, `ruff check src/`, `pytest -x`
4. Generate report with severity, location, description, suggested fix
5. Block commit if CRITICAL or HIGH issues found
## Checklist
### CRITICAL (Block)
- Hardcoded credentials, API keys, tokens, passwords
- SQL injection (must use parameterized queries)
- Path traversal risks
- Missing input validation on API endpoints
- Missing authentication/authorization
### HIGH (Block)
- Functions > 50 lines, files > 800 lines
- Nesting depth > 4 levels
- Missing error handling or bare `except:`
- `print()` in production code (use logging)
- Mutable default arguments
### MEDIUM (Warn)
- Missing type hints on public functions
- Missing tests for new code
- Duplicate code, magic numbers
- Unused imports/variables
- TODO/FIXME comments
## Report Format
```
[SEVERITY] file:line - Issue description
Suggested fix: ...
```
## Never Approve Code With Security Vulnerabilities!

40
.claude/commands/e2e.md Normal file
View File

@@ -0,0 +1,40 @@
# E2E Testing
End-to-end testing for the Invoice Field Extraction API.
## When to Use
- Testing complete inference pipeline (PDF -> Fields)
- Verifying API endpoints work end-to-end
- Validating YOLO + OCR + field extraction integration
- Pre-deployment verification
## Workflow
1. Ensure server is running: `python run_server.py`
2. Run health check: `curl http://localhost:8000/api/v1/health`
3. Run E2E tests: `pytest tests/e2e/ -v`
4. Verify results and capture any failures
## Critical Scenarios (Must Pass)
1. Health check returns `{"status": "healthy", "model_loaded": true}`
2. PDF upload returns valid response with fields
3. Fields extracted with confidence scores
4. Visualization image generated
5. Cross-validation included for invoices with payment_line
## Checklist
- [ ] Server running on http://localhost:8000
- [ ] Health check passes
- [ ] PDF inference returns valid JSON
- [ ] At least one field extracted
- [ ] Visualization URL returns image
- [ ] Response time < 10 seconds
- [ ] No server errors in logs
## Test Location
E2E tests: `tests/e2e/`
Sample fixtures: `tests/fixtures/`

174
.claude/commands/eval.md Normal file
View File

@@ -0,0 +1,174 @@
# Eval Command
Evaluate model performance and field extraction accuracy.
## Usage
`/eval [model|accuracy|compare|report]`
## Model Evaluation
`/eval model`
Evaluate YOLO model performance on test dataset:
```bash
# Run model evaluation
python -m src.cli.train --model runs/train/invoice_fields/weights/best.pt --eval-only
# Or use ultralytics directly
yolo val model=runs/train/invoice_fields/weights/best.pt data=data.yaml
```
Output:
```
Model Evaluation: invoice_fields/best.pt
========================================
mAP@0.5: 93.5%
mAP@0.5-0.95: 83.0%
Per-class AP:
- invoice_number: 95.2%
- invoice_date: 94.8%
- invoice_due_date: 93.1%
- ocr_number: 91.5%
- bankgiro: 92.3%
- plusgiro: 90.8%
- amount: 88.7%
- supplier_org_num: 85.2%
- payment_line: 82.4%
- customer_number: 81.1%
```
## Accuracy Evaluation
`/eval accuracy`
Evaluate field extraction accuracy against ground truth:
```bash
# Run accuracy evaluation on labeled data
python -m src.cli.infer --model runs/train/invoice_fields/weights/best.pt \
--input ~/invoice-data/test/*.pdf \
--ground-truth ~/invoice-data/test/labels.csv \
--output eval_results.json
```
Output:
```
Field Extraction Accuracy
=========================
Documents tested: 500
Per-field accuracy:
- InvoiceNumber: 98.9% (494/500)
- InvoiceDate: 95.5% (478/500)
- InvoiceDueDate: 95.9% (480/500)
- OCR: 99.1% (496/500)
- Bankgiro: 99.0% (495/500)
- Plusgiro: 99.4% (497/500)
- Amount: 91.3% (457/500)
- supplier_org: 78.2% (391/500)
Overall: 94.8%
```
## Compare Models
`/eval compare`
Compare two model versions:
```bash
# Compare old vs new model
python -m src.cli.eval compare \
--model-a runs/train/invoice_v1/weights/best.pt \
--model-b runs/train/invoice_v2/weights/best.pt \
--test-data ~/invoice-data/test/
```
Output:
```
Model Comparison
================
Model A Model B Delta
mAP@0.5: 91.2% 93.5% +2.3%
Accuracy: 92.1% 94.8% +2.7%
Speed (ms): 1850 1520 -330
Per-field improvements:
- amount: +4.2%
- payment_line: +3.8%
- customer_num: +2.1%
Recommendation: Deploy Model B
```
## Generate Report
`/eval report`
Generate comprehensive evaluation report:
```bash
python -m src.cli.eval report --output eval_report.md
```
Output:
```markdown
# Evaluation Report
Generated: 2026-01-25
## Model Performance
- Model: runs/train/invoice_fields/weights/best.pt
- mAP@0.5: 93.5%
- Training samples: 9,738
## Field Extraction Accuracy
| Field | Accuracy | Errors |
|-------|----------|--------|
| InvoiceNumber | 98.9% | 6 |
| Amount | 91.3% | 43 |
...
## Error Analysis
### Common Errors
1. Amount: OCR misreads comma as period
2. supplier_org: Missing from some invoices
3. payment_line: Partially obscured by stamps
## Recommendations
1. Add more training data for low-accuracy fields
2. Implement OCR error correction for amounts
3. Consider confidence threshold tuning
```
## Quick Commands
```bash
# Evaluate model metrics
yolo val model=runs/train/invoice_fields/weights/best.pt
# Test inference on sample
python -m src.cli.infer --input sample.pdf --output result.json --gpu
# Check test coverage
pytest --cov=src --cov-report=html
```
## Evaluation Metrics
| Metric | Target | Current |
|--------|--------|---------|
| mAP@0.5 | >90% | 93.5% |
| Overall Accuracy | >90% | 94.8% |
| Test Coverage | >60% | 37% |
| Tests Passing | 100% | 100% |
## When to Evaluate
- After training a new model
- Before deploying to production
- After adding new training data
- When accuracy complaints arise
- Weekly performance monitoring

70
.claude/commands/learn.md Normal file
View File

@@ -0,0 +1,70 @@
# /learn - Extract Reusable Patterns
Analyze the current session and extract any patterns worth saving as skills.
## Trigger
Run `/learn` at any point during a session when you've solved a non-trivial problem.
## What to Extract
Look for:
1. **Error Resolution Patterns**
- What error occurred?
- What was the root cause?
- What fixed it?
- Is this reusable for similar errors?
2. **Debugging Techniques**
- Non-obvious debugging steps
- Tool combinations that worked
- Diagnostic patterns
3. **Workarounds**
- Library quirks
- API limitations
- Version-specific fixes
4. **Project-Specific Patterns**
- Codebase conventions discovered
- Architecture decisions made
- Integration patterns
## Output Format
Create a skill file at `~/.claude/skills/learned/[pattern-name].md`:
```markdown
# [Descriptive Pattern Name]
**Extracted:** [Date]
**Context:** [Brief description of when this applies]
## Problem
[What problem this solves - be specific]
## Solution
[The pattern/technique/workaround]
## Example
[Code example if applicable]
## When to Use
[Trigger conditions - what should activate this skill]
```
## Process
1. Review the session for extractable patterns
2. Identify the most valuable/reusable insight
3. Draft the skill file
4. Ask user to confirm before saving
5. Save to `~/.claude/skills/learned/`
## Notes
- Don't extract trivial fixes (typos, simple syntax errors)
- Don't extract one-time issues (specific API outages, etc.)
- Focus on patterns that will save time in future sessions
- Keep skills focused - one pattern per skill

View File

@@ -0,0 +1,172 @@
# Orchestrate Command
Sequential agent workflow for complex tasks.
## Usage
`/orchestrate [workflow-type] [task-description]`
## Workflow Types
### feature
Full feature implementation workflow:
```
planner -> tdd-guide -> code-reviewer -> security-reviewer
```
### bugfix
Bug investigation and fix workflow:
```
explorer -> tdd-guide -> code-reviewer
```
### refactor
Safe refactoring workflow:
```
architect -> code-reviewer -> tdd-guide
```
### security
Security-focused review:
```
security-reviewer -> code-reviewer -> architect
```
## Execution Pattern
For each agent in the workflow:
1. **Invoke agent** with context from previous agent
2. **Collect output** as structured handoff document
3. **Pass to next agent** in chain
4. **Aggregate results** into final report
## Handoff Document Format
Between agents, create handoff document:
```markdown
## HANDOFF: [previous-agent] -> [next-agent]
### Context
[Summary of what was done]
### Findings
[Key discoveries or decisions]
### Files Modified
[List of files touched]
### Open Questions
[Unresolved items for next agent]
### Recommendations
[Suggested next steps]
```
## Example: Feature Workflow
```
/orchestrate feature "Add user authentication"
```
Executes:
1. **Planner Agent**
- Analyzes requirements
- Creates implementation plan
- Identifies dependencies
- Output: `HANDOFF: planner -> tdd-guide`
2. **TDD Guide Agent**
- Reads planner handoff
- Writes tests first
- Implements to pass tests
- Output: `HANDOFF: tdd-guide -> code-reviewer`
3. **Code Reviewer Agent**
- Reviews implementation
- Checks for issues
- Suggests improvements
- Output: `HANDOFF: code-reviewer -> security-reviewer`
4. **Security Reviewer Agent**
- Security audit
- Vulnerability check
- Final approval
- Output: Final Report
## Final Report Format
```
ORCHESTRATION REPORT
====================
Workflow: feature
Task: Add user authentication
Agents: planner -> tdd-guide -> code-reviewer -> security-reviewer
SUMMARY
-------
[One paragraph summary]
AGENT OUTPUTS
-------------
Planner: [summary]
TDD Guide: [summary]
Code Reviewer: [summary]
Security Reviewer: [summary]
FILES CHANGED
-------------
[List all files modified]
TEST RESULTS
------------
[Test pass/fail summary]
SECURITY STATUS
---------------
[Security findings]
RECOMMENDATION
--------------
[SHIP / NEEDS WORK / BLOCKED]
```
## Parallel Execution
For independent checks, run agents in parallel:
```markdown
### Parallel Phase
Run simultaneously:
- code-reviewer (quality)
- security-reviewer (security)
- architect (design)
### Merge Results
Combine outputs into single report
```
## Arguments
$ARGUMENTS:
- `feature <description>` - Full feature workflow
- `bugfix <description>` - Bug fix workflow
- `refactor <description>` - Refactoring workflow
- `security <description>` - Security review workflow
- `custom <agents> <description>` - Custom agent sequence
## Custom Workflow Example
```
/orchestrate custom "architect,tdd-guide,code-reviewer" "Redesign caching layer"
```
## Tips
1. **Start with planner** for complex features
2. **Always include code-reviewer** before merge
3. **Use security-reviewer** for auth/payment/PII
4. **Keep handoffs concise** - focus on what next agent needs
5. **Run verification** between agents if needed

113
.claude/commands/plan.md Normal file
View File

@@ -0,0 +1,113 @@
---
description: Restate requirements, assess risks, and create step-by-step implementation plan. WAIT for user CONFIRM before touching any code.
---
# Plan Command
This command invokes the **planner** agent to create a comprehensive implementation plan before writing any code.
## What This Command Does
1. **Restate Requirements** - Clarify what needs to be built
2. **Identify Risks** - Surface potential issues and blockers
3. **Create Step Plan** - Break down implementation into phases
4. **Wait for Confirmation** - MUST receive user approval before proceeding
## When to Use
Use `/plan` when:
- Starting a new feature
- Making significant architectural changes
- Working on complex refactoring
- Multiple files/components will be affected
- Requirements are unclear or ambiguous
## How It Works
The planner agent will:
1. **Analyze the request** and restate requirements in clear terms
2. **Break down into phases** with specific, actionable steps
3. **Identify dependencies** between components
4. **Assess risks** and potential blockers
5. **Estimate complexity** (High/Medium/Low)
6. **Present the plan** and WAIT for your explicit confirmation
## Example Usage
```
User: /plan I need to add real-time notifications when markets resolve
Agent (planner):
# Implementation Plan: Real-Time Market Resolution Notifications
## Requirements Restatement
- Send notifications to users when markets they're watching resolve
- Support multiple notification channels (in-app, email, webhook)
- Ensure notifications are delivered reliably
- Include market outcome and user's position result
## Implementation Phases
### Phase 1: Database Schema
- Add notifications table with columns: id, user_id, market_id, type, status, created_at
- Add user_notification_preferences table for channel preferences
- Create indexes on user_id and market_id for performance
### Phase 2: Notification Service
- Create notification service in lib/notifications.ts
- Implement notification queue using BullMQ/Redis
- Add retry logic for failed deliveries
- Create notification templates
### Phase 3: Integration Points
- Hook into market resolution logic (when status changes to "resolved")
- Query all users with positions in market
- Enqueue notifications for each user
### Phase 4: Frontend Components
- Create NotificationBell component in header
- Add NotificationList modal
- Implement real-time updates via Supabase subscriptions
- Add notification preferences page
## Dependencies
- Redis (for queue)
- Email service (SendGrid/Resend)
- Supabase real-time subscriptions
## Risks
- HIGH: Email deliverability (SPF/DKIM required)
- MEDIUM: Performance with 1000+ users per market
- MEDIUM: Notification spam if markets resolve frequently
- LOW: Real-time subscription overhead
## Estimated Complexity: MEDIUM
- Backend: 4-6 hours
- Frontend: 3-4 hours
- Testing: 2-3 hours
- Total: 9-13 hours
**WAITING FOR CONFIRMATION**: Proceed with this plan? (yes/no/modify)
```
## Important Notes
**CRITICAL**: The planner agent will **NOT** write any code until you explicitly confirm the plan with "yes" or "proceed" or similar affirmative response.
If you want changes, respond with:
- "modify: [your changes]"
- "different approach: [alternative]"
- "skip phase 2 and do phase 3 first"
## Integration with Other Commands
After planning:
- Use `/tdd` to implement with test-driven development
- Use `/build-and-fix` if build errors occur
- Use `/code-review` to review completed implementation
## Related Agents
This command invokes the `planner` agent located at:
`~/.claude/agents/planner.md`

View File

@@ -0,0 +1,28 @@
# Refactor Clean
Safely identify and remove dead code with test verification:
1. Run dead code analysis tools:
- knip: Find unused exports and files
- depcheck: Find unused dependencies
- ts-prune: Find unused TypeScript exports
2. Generate comprehensive report in .reports/dead-code-analysis.md
3. Categorize findings by severity:
- SAFE: Test files, unused utilities
- CAUTION: API routes, components
- DANGER: Config files, main entry points
4. Propose safe deletions only
5. Before each deletion:
- Run full test suite
- Verify tests pass
- Apply change
- Re-run tests
- Rollback if tests fail
6. Show summary of cleaned items
Never delete code without running tests first!

View File

@@ -0,0 +1,80 @@
---
description: Configure your preferred package manager (npm/pnpm/yarn/bun)
disable-model-invocation: true
---
# Package Manager Setup
Configure your preferred package manager for this project or globally.
## Usage
```bash
# Detect current package manager
node scripts/setup-package-manager.js --detect
# Set global preference
node scripts/setup-package-manager.js --global pnpm
# Set project preference
node scripts/setup-package-manager.js --project bun
# List available package managers
node scripts/setup-package-manager.js --list
```
## Detection Priority
When determining which package manager to use, the following order is checked:
1. **Environment variable**: `CLAUDE_PACKAGE_MANAGER`
2. **Project config**: `.claude/package-manager.json`
3. **package.json**: `packageManager` field
4. **Lock file**: Presence of package-lock.json, yarn.lock, pnpm-lock.yaml, or bun.lockb
5. **Global config**: `~/.claude/package-manager.json`
6. **Fallback**: First available package manager (pnpm > bun > yarn > npm)
## Configuration Files
### Global Configuration
```json
// ~/.claude/package-manager.json
{
"packageManager": "pnpm"
}
```
### Project Configuration
```json
// .claude/package-manager.json
{
"packageManager": "bun"
}
```
### package.json
```json
{
"packageManager": "pnpm@8.6.0"
}
```
## Environment Variable
Set `CLAUDE_PACKAGE_MANAGER` to override all other detection methods:
```bash
# Windows (PowerShell)
$env:CLAUDE_PACKAGE_MANAGER = "pnpm"
# macOS/Linux
export CLAUDE_PACKAGE_MANAGER=pnpm
```
## Run the Detection
To see current package manager detection results, run:
```bash
node scripts/setup-package-manager.js --detect
```

326
.claude/commands/tdd.md Normal file
View File

@@ -0,0 +1,326 @@
---
description: Enforce test-driven development workflow. Scaffold interfaces, generate tests FIRST, then implement minimal code to pass. Ensure 80%+ coverage.
---
# TDD Command
This command invokes the **tdd-guide** agent to enforce test-driven development methodology.
## What This Command Does
1. **Scaffold Interfaces** - Define types/interfaces first
2. **Generate Tests First** - Write failing tests (RED)
3. **Implement Minimal Code** - Write just enough to pass (GREEN)
4. **Refactor** - Improve code while keeping tests green (REFACTOR)
5. **Verify Coverage** - Ensure 80%+ test coverage
## When to Use
Use `/tdd` when:
- Implementing new features
- Adding new functions/components
- Fixing bugs (write test that reproduces bug first)
- Refactoring existing code
- Building critical business logic
## How It Works
The tdd-guide agent will:
1. **Define interfaces** for inputs/outputs
2. **Write tests that will FAIL** (because code doesn't exist yet)
3. **Run tests** and verify they fail for the right reason
4. **Write minimal implementation** to make tests pass
5. **Run tests** and verify they pass
6. **Refactor** code while keeping tests green
7. **Check coverage** and add more tests if below 80%
## TDD Cycle
```
RED → GREEN → REFACTOR → REPEAT
RED: Write a failing test
GREEN: Write minimal code to pass
REFACTOR: Improve code, keep tests passing
REPEAT: Next feature/scenario
```
## Example Usage
```
User: /tdd I need a function to calculate market liquidity score
Agent (tdd-guide):
# TDD Session: Market Liquidity Score Calculator
## Step 1: Define Interface (SCAFFOLD)
```typescript
// lib/liquidity.ts
export interface MarketData {
totalVolume: number
bidAskSpread: number
activeTraders: number
lastTradeTime: Date
}
export function calculateLiquidityScore(market: MarketData): number {
// TODO: Implementation
throw new Error('Not implemented')
}
```
## Step 2: Write Failing Test (RED)
```typescript
// lib/liquidity.test.ts
import { calculateLiquidityScore } from './liquidity'
describe('calculateLiquidityScore', () => {
it('should return high score for liquid market', () => {
const market = {
totalVolume: 100000,
bidAskSpread: 0.01,
activeTraders: 500,
lastTradeTime: new Date()
}
const score = calculateLiquidityScore(market)
expect(score).toBeGreaterThan(80)
expect(score).toBeLessThanOrEqual(100)
})
it('should return low score for illiquid market', () => {
const market = {
totalVolume: 100,
bidAskSpread: 0.5,
activeTraders: 2,
lastTradeTime: new Date(Date.now() - 86400000) // 1 day ago
}
const score = calculateLiquidityScore(market)
expect(score).toBeLessThan(30)
expect(score).toBeGreaterThanOrEqual(0)
})
it('should handle edge case: zero volume', () => {
const market = {
totalVolume: 0,
bidAskSpread: 0,
activeTraders: 0,
lastTradeTime: new Date()
}
const score = calculateLiquidityScore(market)
expect(score).toBe(0)
})
})
```
## Step 3: Run Tests - Verify FAIL
```bash
npm test lib/liquidity.test.ts
FAIL lib/liquidity.test.ts
✕ should return high score for liquid market (2 ms)
Error: Not implemented
1 test failed, 0 passed
```
✅ Tests fail as expected. Ready to implement.
## Step 4: Implement Minimal Code (GREEN)
```typescript
// lib/liquidity.ts
export function calculateLiquidityScore(market: MarketData): number {
// Handle zero volume edge case
if (market.totalVolume === 0) {
return 0
}
// Calculate component scores (0-100 scale)
const volumeScore = Math.min(market.totalVolume / 1000, 100)
const spreadScore = Math.max(100 - (market.bidAskSpread * 1000), 0)
const traderScore = Math.min(market.activeTraders / 10, 100)
// Recent activity bonus
const hoursSinceLastTrade = (Date.now() - market.lastTradeTime.getTime()) / (1000 * 60 * 60)
const recencyScore = Math.max(100 - (hoursSinceLastTrade * 10), 0)
// Weighted average
const score = (
volumeScore * 0.4 +
spreadScore * 0.3 +
traderScore * 0.2 +
recencyScore * 0.1
)
return Math.min(Math.max(score, 0), 100) // Clamp to 0-100
}
```
## Step 5: Run Tests - Verify PASS
```bash
npm test lib/liquidity.test.ts
PASS lib/liquidity.test.ts
✓ should return high score for liquid market (3 ms)
✓ should return low score for illiquid market (2 ms)
✓ should handle edge case: zero volume (1 ms)
3 tests passed
```
✅ All tests passing!
## Step 6: Refactor (IMPROVE)
```typescript
// lib/liquidity.ts - Refactored with constants and better readability
const WEIGHTS = {
VOLUME: 0.4,
SPREAD: 0.3,
TRADERS: 0.2,
RECENCY: 0.1,
} as const
const SCALE_FACTORS = {
VOLUME: 1000,
SPREAD: 1000,
TRADERS: 10,
RECENCY_PENALTY: 10,
} as const
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
export function calculateLiquidityScore(market: MarketData): number {
if (market.totalVolume === 0) return 0
const volumeScore = Math.min(market.totalVolume / SCALE_FACTORS.VOLUME, 100)
const spreadScore = clamp(100 - (market.bidAskSpread * SCALE_FACTORS.SPREAD), 0, 100)
const traderScore = Math.min(market.activeTraders / SCALE_FACTORS.TRADERS, 100)
const hoursSinceLastTrade = (Date.now() - market.lastTradeTime.getTime()) / (1000 * 60 * 60)
const recencyScore = clamp(100 - (hoursSinceLastTrade * SCALE_FACTORS.RECENCY_PENALTY), 0, 100)
const weightedScore =
volumeScore * WEIGHTS.VOLUME +
spreadScore * WEIGHTS.SPREAD +
traderScore * WEIGHTS.TRADERS +
recencyScore * WEIGHTS.RECENCY
return clamp(weightedScore, 0, 100)
}
```
## Step 7: Verify Tests Still Pass
```bash
npm test lib/liquidity.test.ts
PASS lib/liquidity.test.ts
✓ should return high score for liquid market (3 ms)
✓ should return low score for illiquid market (2 ms)
✓ should handle edge case: zero volume (1 ms)
3 tests passed
```
✅ Refactoring complete, tests still passing!
## Step 8: Check Coverage
```bash
npm test -- --coverage lib/liquidity.test.ts
File | % Stmts | % Branch | % Funcs | % Lines
---------------|---------|----------|---------|--------
liquidity.ts | 100 | 100 | 100 | 100
Coverage: 100% ✅ (Target: 80%)
```
✅ TDD session complete!
```
## TDD Best Practices
**DO:**
- ✅ Write the test FIRST, before any implementation
- ✅ Run tests and verify they FAIL before implementing
- ✅ Write minimal code to make tests pass
- ✅ Refactor only after tests are green
- ✅ Add edge cases and error scenarios
- ✅ Aim for 80%+ coverage (100% for critical code)
**DON'T:**
- ❌ Write implementation before tests
- ❌ Skip running tests after each change
- ❌ Write too much code at once
- ❌ Ignore failing tests
- ❌ Test implementation details (test behavior)
- ❌ Mock everything (prefer integration tests)
## Test Types to Include
**Unit Tests** (Function-level):
- Happy path scenarios
- Edge cases (empty, null, max values)
- Error conditions
- Boundary values
**Integration Tests** (Component-level):
- API endpoints
- Database operations
- External service calls
- React components with hooks
**E2E Tests** (use `/e2e` command):
- Critical user flows
- Multi-step processes
- Full stack integration
## Coverage Requirements
- **80% minimum** for all code
- **100% required** for:
- Financial calculations
- Authentication logic
- Security-critical code
- Core business logic
## Important Notes
**MANDATORY**: Tests must be written BEFORE implementation. The TDD cycle is:
1. **RED** - Write failing test
2. **GREEN** - Implement to pass
3. **REFACTOR** - Improve code
Never skip the RED phase. Never write code before tests.
## Integration with Other Commands
- Use `/plan` first to understand what to build
- Use `/tdd` to implement with tests
- Use `/build-and-fix` if build errors occur
- Use `/code-review` to review implementation
- Use `/test-coverage` to verify coverage
## Related Agents
This command invokes the `tdd-guide` agent located at:
`~/.claude/agents/tdd-guide.md`
And can reference the `tdd-workflow` skill at:
`~/.claude/skills/tdd-workflow/`

View File

@@ -0,0 +1,27 @@
# Test Coverage
Analyze test coverage and generate missing tests:
1. Run tests with coverage: npm test --coverage or pnpm test --coverage
2. Analyze coverage report (coverage/coverage-summary.json)
3. Identify files below 80% coverage threshold
4. For each under-covered file:
- Analyze untested code paths
- Generate unit tests for functions
- Generate integration tests for APIs
- Generate E2E tests for critical flows
5. Verify new tests pass
6. Show before/after coverage metrics
7. Ensure project reaches 80%+ overall coverage
Focus on:
- Happy path scenarios
- Error handling
- Edge cases (null, undefined, empty)
- Boundary conditions

View File

@@ -0,0 +1,17 @@
# Update Codemaps
Analyze the codebase structure and update architecture documentation:
1. Scan all source files for imports, exports, and dependencies
2. Generate token-lean codemaps in the following format:
- codemaps/architecture.md - Overall architecture
- codemaps/backend.md - Backend structure
- codemaps/frontend.md - Frontend structure
- codemaps/data.md - Data models and schemas
3. Calculate diff percentage from previous version
4. If changes > 30%, request user approval before updating
5. Add freshness timestamp to each codemap
6. Save reports to .reports/codemap-diff.txt
Use TypeScript/Node.js for analysis. Focus on high-level structure, not implementation details.

View File

@@ -0,0 +1,31 @@
# Update Documentation
Sync documentation from source-of-truth:
1. Read package.json scripts section
- Generate scripts reference table
- Include descriptions from comments
2. Read .env.example
- Extract all environment variables
- Document purpose and format
3. Generate docs/CONTRIB.md with:
- Development workflow
- Available scripts
- Environment setup
- Testing procedures
4. Generate docs/RUNBOOK.md with:
- Deployment procedures
- Monitoring and alerts
- Common issues and fixes
- Rollback procedures
5. Identify obsolete documentation:
- Find docs not modified in 90+ days
- List for manual review
6. Show diff summary
Single source of truth: package.json and .env.example

View File

@@ -0,0 +1,59 @@
# Verification Command
Run comprehensive verification on current codebase state.
## Instructions
Execute verification in this exact order:
1. **Build Check**
- Run the build command for this project
- If it fails, report errors and STOP
2. **Type Check**
- Run TypeScript/type checker
- Report all errors with file:line
3. **Lint Check**
- Run linter
- Report warnings and errors
4. **Test Suite**
- Run all tests
- Report pass/fail count
- Report coverage percentage
5. **Console.log Audit**
- Search for console.log in source files
- Report locations
6. **Git Status**
- Show uncommitted changes
- Show files modified since last commit
## Output
Produce a concise verification report:
```
VERIFICATION: [PASS/FAIL]
Build: [OK/FAIL]
Types: [OK/X errors]
Lint: [OK/X issues]
Tests: [X/Y passed, Z% coverage]
Secrets: [OK/X found]
Logs: [OK/X console.logs]
Ready for PR: [YES/NO]
```
If any critical issues, list them with fix suggestions.
## Arguments
$ARGUMENTS can be:
- `quick` - Only build + types
- `full` - All checks (default)
- `pre-commit` - Checks relevant for commits
- `pre-pr` - Full checks plus security scan

7
.claude/config.toml Normal file
View File

@@ -0,0 +1,7 @@
[permissions]
read = true
write = true
execute = true
[permissions.scope]
paths = ["."]

157
.claude/hooks/hooks.json Normal file
View File

@@ -0,0 +1,157 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"PreToolUse": [
{
"matcher": "tool == \"Bash\" && tool_input.command matches \"(npm run dev|pnpm( run)? dev|yarn dev|bun run dev)\"",
"hooks": [
{
"type": "command",
"command": "node -e \"console.error('[Hook] BLOCKED: Dev server must run in tmux for log access');console.error('[Hook] Use: tmux new-session -d -s dev \\\"npm run dev\\\"');console.error('[Hook] Then: tmux attach -t dev');process.exit(1)\""
}
],
"description": "Block dev servers outside tmux - ensures you can access logs"
},
{
"matcher": "tool == \"Bash\" && tool_input.command matches \"(npm (install|test)|pnpm (install|test)|yarn (install|test)?|bun (install|test)|cargo build|make|docker|pytest|vitest|playwright)\"",
"hooks": [
{
"type": "command",
"command": "node -e \"if(!process.env.TMUX){console.error('[Hook] Consider running in tmux for session persistence');console.error('[Hook] tmux new -s dev | tmux attach -t dev')}\""
}
],
"description": "Reminder to use tmux for long-running commands"
},
{
"matcher": "tool == \"Bash\" && tool_input.command matches \"git push\"",
"hooks": [
{
"type": "command",
"command": "node -e \"console.error('[Hook] Review changes before push...');console.error('[Hook] Continuing with push (remove this hook to add interactive review)')\""
}
],
"description": "Reminder before git push to review changes"
},
{
"matcher": "tool == \"Write\" && tool_input.file_path matches \"\\\\.(md|txt)$\" && !(tool_input.file_path matches \"README\\\\.md|CLAUDE\\\\.md|AGENTS\\\\.md|CONTRIBUTING\\\\.md\")",
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs');let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const p=i.tool_input?.file_path||'';if(/\\.(md|txt)$/.test(p)&&!/(README|CLAUDE|AGENTS|CONTRIBUTING)\\.md$/.test(p)){console.error('[Hook] BLOCKED: Unnecessary documentation file creation');console.error('[Hook] File: '+p);console.error('[Hook] Use README.md for documentation instead');process.exit(1)}console.log(d)})\""
}
],
"description": "Block creation of random .md files - keeps docs consolidated"
},
{
"matcher": "tool == \"Edit\" || tool == \"Write\"",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/suggest-compact.js\""
}
],
"description": "Suggest manual compaction at logical intervals"
}
],
"PreCompact": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/pre-compact.js\""
}
],
"description": "Save state before context compaction"
}
],
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/session-start.js\""
}
],
"description": "Load previous context and detect package manager on new session"
}
],
"PostToolUse": [
{
"matcher": "tool == \"Bash\"",
"hooks": [
{
"type": "command",
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const cmd=i.tool_input?.command||'';if(/gh pr create/.test(cmd)){const out=i.tool_output?.output||'';const m=out.match(/https:\\/\\/github.com\\/[^/]+\\/[^/]+\\/pull\\/\\d+/);if(m){console.error('[Hook] PR created: '+m[0]);const repo=m[0].replace(/https:\\/\\/github.com\\/([^/]+\\/[^/]+)\\/pull\\/\\d+/,'$1');const pr=m[0].replace(/.*\\/pull\\/(\\d+)/,'$1');console.error('[Hook] To review: gh pr review '+pr+' --repo '+repo)}}console.log(d)})\""
}
],
"description": "Log PR URL and provide review command after PR creation"
},
{
"matcher": "tool == \"Edit\" && tool_input.file_path matches \"\\\\.(ts|tsx|js|jsx)$\"",
"hooks": [
{
"type": "command",
"command": "node -e \"const{execSync}=require('child_process');const fs=require('fs');let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const p=i.tool_input?.file_path;if(p&&fs.existsSync(p)){try{execSync('npx prettier --write \"'+p+'\"',{stdio:['pipe','pipe','pipe']})}catch(e){}}console.log(d)})\""
}
],
"description": "Auto-format JS/TS files with Prettier after edits"
},
{
"matcher": "tool == \"Edit\" && tool_input.file_path matches \"\\\\.(ts|tsx)$\"",
"hooks": [
{
"type": "command",
"command": "node -e \"const{execSync}=require('child_process');const fs=require('fs');const path=require('path');let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const p=i.tool_input?.file_path;if(p&&fs.existsSync(p)){let dir=path.dirname(p);while(dir!==path.dirname(dir)&&!fs.existsSync(path.join(dir,'tsconfig.json'))){dir=path.dirname(dir)}if(fs.existsSync(path.join(dir,'tsconfig.json'))){try{const r=execSync('npx tsc --noEmit --pretty false 2>&1',{cwd:dir,encoding:'utf8',stdio:['pipe','pipe','pipe']});const lines=r.split('\\n').filter(l=>l.includes(p)).slice(0,10);if(lines.length)console.error(lines.join('\\n'))}catch(e){const lines=(e.stdout||'').split('\\n').filter(l=>l.includes(p)).slice(0,10);if(lines.length)console.error(lines.join('\\n'))}}}console.log(d)})\""
}
],
"description": "TypeScript check after editing .ts/.tsx files"
},
{
"matcher": "tool == \"Edit\" && tool_input.file_path matches \"\\\\.(ts|tsx|js|jsx)$\"",
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs');let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const p=i.tool_input?.file_path;if(p&&fs.existsSync(p)){const c=fs.readFileSync(p,'utf8');const lines=c.split('\\n');const matches=[];lines.forEach((l,idx)=>{if(/console\\.log/.test(l))matches.push((idx+1)+': '+l.trim())});if(matches.length){console.error('[Hook] WARNING: console.log found in '+p);matches.slice(0,5).forEach(m=>console.error(m));console.error('[Hook] Remove console.log before committing')}}console.log(d)})\""
}
],
"description": "Warn about console.log statements after edits"
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node -e \"const{execSync}=require('child_process');const fs=require('fs');let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{execSync('git rev-parse --git-dir',{stdio:'pipe'})}catch{console.log(d);process.exit(0)}try{const files=execSync('git diff --name-only HEAD',{encoding:'utf8',stdio:['pipe','pipe','pipe']}).split('\\n').filter(f=>/\\.(ts|tsx|js|jsx)$/.test(f)&&fs.existsSync(f));let hasConsole=false;for(const f of files){if(fs.readFileSync(f,'utf8').includes('console.log')){console.error('[Hook] WARNING: console.log found in '+f);hasConsole=true}}if(hasConsole)console.error('[Hook] Remove console.log statements before committing')}catch(e){}console.log(d)})\""
}
],
"description": "Check for console.log in modified files after each response"
}
],
"SessionEnd": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/session-end.js\""
}
],
"description": "Persist session state on end"
},
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/evaluate-session.js\""
}
],
"description": "Evaluate session for extractable patterns"
}
]
}
}

View File

@@ -0,0 +1,36 @@
#!/bin/bash
# PreCompact Hook - Save state before context compaction
#
# Runs before Claude compacts context, giving you a chance to
# preserve important state that might get lost in summarization.
#
# Hook config (in ~/.claude/settings.json):
# {
# "hooks": {
# "PreCompact": [{
# "matcher": "*",
# "hooks": [{
# "type": "command",
# "command": "~/.claude/hooks/memory-persistence/pre-compact.sh"
# }]
# }]
# }
# }
SESSIONS_DIR="${HOME}/.claude/sessions"
COMPACTION_LOG="${SESSIONS_DIR}/compaction-log.txt"
mkdir -p "$SESSIONS_DIR"
# Log compaction event with timestamp
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Context compaction triggered" >> "$COMPACTION_LOG"
# If there's an active session file, note the compaction
ACTIVE_SESSION=$(ls -t "$SESSIONS_DIR"/*.tmp 2>/dev/null | head -1)
if [ -n "$ACTIVE_SESSION" ]; then
echo "" >> "$ACTIVE_SESSION"
echo "---" >> "$ACTIVE_SESSION"
echo "**[Compaction occurred at $(date '+%H:%M')]** - Context was summarized" >> "$ACTIVE_SESSION"
fi
echo "[PreCompact] State saved before compaction" >&2

View File

@@ -0,0 +1,61 @@
#!/bin/bash
# Stop Hook (Session End) - Persist learnings when session ends
#
# Runs when Claude session ends. Creates/updates session log file
# with timestamp for continuity tracking.
#
# Hook config (in ~/.claude/settings.json):
# {
# "hooks": {
# "Stop": [{
# "matcher": "*",
# "hooks": [{
# "type": "command",
# "command": "~/.claude/hooks/memory-persistence/session-end.sh"
# }]
# }]
# }
# }
SESSIONS_DIR="${HOME}/.claude/sessions"
TODAY=$(date '+%Y-%m-%d')
SESSION_FILE="${SESSIONS_DIR}/${TODAY}-session.tmp"
mkdir -p "$SESSIONS_DIR"
# If session file exists for today, update the end time
if [ -f "$SESSION_FILE" ]; then
# Update Last Updated timestamp
sed -i '' "s/\*\*Last Updated:\*\*.*/\*\*Last Updated:\*\* $(date '+%H:%M')/" "$SESSION_FILE" 2>/dev/null || \
sed -i "s/\*\*Last Updated:\*\*.*/\*\*Last Updated:\*\* $(date '+%H:%M')/" "$SESSION_FILE" 2>/dev/null
echo "[SessionEnd] Updated session file: $SESSION_FILE" >&2
else
# Create new session file with template
cat > "$SESSION_FILE" << EOF
# Session: $(date '+%Y-%m-%d')
**Date:** $TODAY
**Started:** $(date '+%H:%M')
**Last Updated:** $(date '+%H:%M')
---
## Current State
[Session context goes here]
### Completed
- [ ]
### In Progress
- [ ]
### Notes for Next Session
-
### Context to Load
\`\`\`
[relevant files]
\`\`\`
EOF
echo "[SessionEnd] Created session file: $SESSION_FILE" >&2
fi

View File

@@ -0,0 +1,37 @@
#!/bin/bash
# SessionStart Hook - Load previous context on new session
#
# Runs when a new Claude session starts. Checks for recent session
# files and notifies Claude of available context to load.
#
# Hook config (in ~/.claude/settings.json):
# {
# "hooks": {
# "SessionStart": [{
# "matcher": "*",
# "hooks": [{
# "type": "command",
# "command": "~/.claude/hooks/memory-persistence/session-start.sh"
# }]
# }]
# }
# }
SESSIONS_DIR="${HOME}/.claude/sessions"
LEARNED_DIR="${HOME}/.claude/skills/learned"
# Check for recent session files (last 7 days)
recent_sessions=$(find "$SESSIONS_DIR" -name "*.tmp" -mtime -7 2>/dev/null | wc -l | tr -d ' ')
if [ "$recent_sessions" -gt 0 ]; then
latest=$(ls -t "$SESSIONS_DIR"/*.tmp 2>/dev/null | head -1)
echo "[SessionStart] Found $recent_sessions recent session(s)" >&2
echo "[SessionStart] Latest: $latest" >&2
fi
# Check for learned skills
learned_count=$(find "$LEARNED_DIR" -name "*.md" 2>/dev/null | wc -l | tr -d ' ')
if [ "$learned_count" -gt 0 ]; then
echo "[SessionStart] $learned_count learned skill(s) available in $LEARNED_DIR" >&2
fi

View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Strategic Compact Suggester
# Runs on PreToolUse or periodically to suggest manual compaction at logical intervals
#
# Why manual over auto-compact:
# - Auto-compact happens at arbitrary points, often mid-task
# - Strategic compacting preserves context through logical phases
# - Compact after exploration, before execution
# - Compact after completing a milestone, before starting next
#
# Hook config (in ~/.claude/settings.json):
# {
# "hooks": {
# "PreToolUse": [{
# "matcher": "Edit|Write",
# "hooks": [{
# "type": "command",
# "command": "~/.claude/skills/strategic-compact/suggest-compact.sh"
# }]
# }]
# }
# }
#
# Criteria for suggesting compact:
# - Session has been running for extended period
# - Large number of tool calls made
# - Transitioning from research/exploration to implementation
# - Plan has been finalized
# Track tool call count (increment in a temp file)
COUNTER_FILE="/tmp/claude-tool-count-$$"
THRESHOLD=${COMPACT_THRESHOLD:-50}
# Initialize or increment counter
if [ -f "$COUNTER_FILE" ]; then
count=$(cat "$COUNTER_FILE")
count=$((count + 1))
echo "$count" > "$COUNTER_FILE"
else
echo "1" > "$COUNTER_FILE"
count=1
fi
# Suggest compact after threshold tool calls
if [ "$count" -eq "$THRESHOLD" ]; then
echo "[StrategicCompact] $THRESHOLD tool calls reached - consider /compact if transitioning phases" >&2
fi
# Suggest at regular intervals after threshold
if [ "$count" -gt "$THRESHOLD" ] && [ $((count % 25)) -eq 0 ]; then
echo "[StrategicCompact] $count tool calls - good checkpoint for /compact if context is stale" >&2
fi

14
.claude/settings.json Normal file
View File

@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(*)",
"Read(*)",
"Write(*)",
"Edit(*)",
"Glob(*)",
"Grep(*)",
"Task(*)",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && pytest tests/web/test_batch_upload_routes.py::TestBatchUploadRoutes::test_upload_batch_async_mode_default -v -s 2>&1 | head -100\")"
]
}
}

118
.claude/settings.local.json Normal file
View File

@@ -0,0 +1,118 @@
{
"permissions": {
"allow": [
"Bash(*)",
"Bash(wsl*)",
"Bash(wsl -e bash*)",
"Read(*)",
"Write(*)",
"Edit(*)",
"Glob(*)",
"Grep(*)",
"WebFetch(*)",
"WebSearch(*)",
"Task(*)",
"Bash(wsl -e bash -c:*)",
"Bash(powershell -c:*)",
"Bash(dir \"C:\\\\Users\\\\yaoji\\\\git\\\\ColaCoder\\\\invoice-master-poc-v2\\\\runs\\\\detect\\\\runs\\\\train\\\\invoice_fields_v3\"\")",
"Bash(timeout:*)",
"Bash(powershell:*)",
"Bash(wsl -e bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && nvidia-smi 2>/dev/null | head -10\")",
"Bash(wsl -e bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && nohup python3 -m src.cli.train --data data/dataset/dataset.yaml --model yolo11s.pt --epochs 100 --batch 8 --device 0 --name invoice_fields_v4 > training.log 2>&1 &\")",
"Bash(wsl -e bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && sleep 10 && tail -20 training.log 2>/dev/null\":*)",
"Bash(wsl -e bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && cat training.log 2>/dev/null | head -30\")",
"Bash(wsl -e bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && ls -la training.log 2>/dev/null && ps aux | grep python\")",
"Bash(wsl -e bash -c \"ps aux | grep -E ''python|train''\")",
"Bash(wsl -e bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && python3 -m src.cli.train --data data/dataset/dataset.yaml --model yolo11s.pt --epochs 100 --batch 8 --device 0 --name invoice_fields_v4 2>&1 | tee training.log &\")",
"Bash(wsl -e bash -c \"sleep 15 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && tail -15 training.log\":*)",
"Bash(wsl -e bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && python3 -m src.cli.train --data data/dataset/dataset.yaml --model yolo11s.pt --epochs 100 --batch 8 --device 0 --name invoice_fields_v4\")",
"Bash(wsl -e bash -c \"which screen || sudo apt-get install -y screen 2>/dev/null\")",
"Bash(wsl -e bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && python3 -c \"\"\nfrom ultralytics import YOLO\n\n# Load model\nmodel = YOLO\\(''runs/detect/runs/train/invoice_fields_v4/weights/best.pt''\\)\n\n# Run inference on a test image\nresults = model.predict\\(\n ''data/dataset/test/images/36a4fd23-0a66-4428-9149-4f95c93db9cb_page_000.png'',\n conf=0.5,\n save=True,\n project=''results'',\n name=''test_inference''\n\\)\n\n# Print results\nfor r in results:\n print\\(''Image:'', r.path\\)\n print\\(''Boxes:'', len\\(r.boxes\\)\\)\n for box in r.boxes:\n cls = int\\(box.cls[0]\\)\n conf = float\\(box.conf[0]\\)\n name = model.names[cls]\n print\\(f'' - {name}: {conf:.2%}''\\)\n\"\"\")",
"Bash(python:*)",
"Bash(dir:*)",
"Bash(timeout 180 tail:*)",
"Bash(python3:*)",
"Bash(wsl -d Ubuntu-22.04 -- bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && source .venv/bin/activate && python -m src.cli.autolabel --csv ''data/structured_data/document_export_20260110_141554_page1.csv,data/structured_data/document_export_20260110_141612_page2.csv'' --report reports/autolabel_test_2csv.jsonl --workers 2\")",
"Bash(wsl -d Ubuntu-22.04 -- bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice && python -m src.cli.autolabel --csv ''data/structured_data/document_export_20260110_141554_page1.csv,data/structured_data/document_export_20260110_141612_page2.csv'' --report reports/autolabel_test_2csv.jsonl --workers 2\")",
"Bash(wsl -d Ubuntu-22.04 -- bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda info --envs\":*)",
"Bash(wsl:*)",
"Bash(for f in reports/autolabel_shard_test_part*.jsonl)",
"Bash(done)",
"Bash(timeout 10 tail:*)",
"Bash(more:*)",
"Bash(cmd /c type \"C:\\\\Users\\\\yaoji\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-yaoji-git-ColaCoder-invoice-master-poc-v2\\\\tasks\\\\b4d8070.output\")",
"Bash(cmd /c \"dir C:\\\\Users\\\\yaoji\\\\git\\\\ColaCoder\\\\invoice-master-poc-v2\\\\reports\\\\autolabel_report_v4*.jsonl\")",
"Bash(wsl wc:*)",
"Bash(wsl bash:*)",
"Bash(wsl bash -c \"ps aux | grep python | grep -v grep\")",
"Bash(wsl bash -c \"kill -9 130864 130870 414045 414046\")",
"Bash(wsl bash -c \"ps aux | grep python | grep -v grep | grep -v networkd | grep -v unattended\")",
"Bash(wsl bash -c \"kill -9 414046 2>/dev/null; pkill -9 -f autolabel 2>/dev/null; sleep 1; ps aux | grep autolabel | grep -v grep\")",
"Bash(python -m src.cli.import_report_to_db:*)",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && pip install psycopg2-binary\")",
"Bash(conda activate:*)",
"Bash(python -m src.cli.analyze_report:*)",
"Bash(/c/Users/yaoji/miniconda3/envs/yolo11/python.exe -m src.cli.analyze_report:*)",
"Bash(c:/Users/yaoji/miniconda3/envs/yolo11/python.exe -m src.cli.analyze_report:*)",
"Bash(cmd /c \"cd /d C:\\\\Users\\\\yaoji\\\\git\\\\ColaCoder\\\\invoice-master-poc-v2 && C:\\\\Users\\\\yaoji\\\\miniconda3\\\\envs\\\\yolo11\\\\python.exe -m src.cli.analyze_report\")",
"Bash(cmd /c \"cd /d C:\\\\Users\\\\yaoji\\\\git\\\\ColaCoder\\\\invoice-master-poc-v2 && C:\\\\Users\\\\yaoji\\\\miniconda3\\\\envs\\\\yolo11\\\\python.exe -m src.cli.analyze_report 2>&1\")",
"Bash(powershell -Command \"cd C:\\\\Users\\\\yaoji\\\\git\\\\ColaCoder\\\\invoice-master-poc-v2; C:\\\\Users\\\\yaoji\\\\miniconda3\\\\envs\\\\yolo11\\\\python.exe -m src.cli.analyze_report\":*)",
"Bash(powershell -Command \"ls C:\\\\Users\\\\yaoji\\\\miniconda3\\\\envs\"\")",
"Bash(where:*)",
"Bash(\"C:/Users/yaoji/anaconda3/envs/invoice-master/python.exe\" -c \"import psycopg2; print\\(''psycopg2 OK''\\)\")",
"Bash(\"C:/Users/yaoji/anaconda3/envs/torch-gpu/python.exe\" -c \"import psycopg2; print\\(''psycopg2 OK''\\)\")",
"Bash(\"C:/Users/yaoji/anaconda3/python.exe\" -c \"import psycopg2; print\\(''psycopg2 OK''\\)\")",
"Bash(\"C:/Users/yaoji/anaconda3/envs/invoice-master/python.exe\" -m pip install psycopg2-binary)",
"Bash(\"C:/Users/yaoji/anaconda3/envs/invoice-master/python.exe\" -m src.cli.analyze_report)",
"Bash(wsl -d Ubuntu bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && conda activate invoice-master && python -m src.cli.autolabel --help\":*)",
"Bash(wsl -d Ubuntu-22.04 bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-extract && python -m src.cli.train --export-only 2>&1\")",
"Bash(wsl -d Ubuntu-22.04 bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && ls -la data/dataset/\")",
"Bash(wsl -d Ubuntu-22.04 bash -c \"ps aux | grep python | grep -v grep\")",
"Bash(wsl -d Ubuntu-22.04 bash -c:*)",
"Bash(wsl -e bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && ls -la\")",
"Bash(wsl -e bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-master && python -c \"\"\nimport sys\nsys.path.insert\\(0, ''.''\\)\nfrom src.data.db import DocumentDB\nfrom src.yolo.db_dataset import DBYOLODataset\n\n# Connect to database\ndb = DocumentDB\\(\\)\ndb.connect\\(\\)\n\n# Create dataset\ndataset = DBYOLODataset\\(\n images_dir=''data/dataset'',\n db=db,\n split=''train'',\n train_ratio=0.8,\n val_ratio=0.1,\n seed=42,\n dpi=300\n\\)\n\nprint\\(f''Dataset size: {len\\(dataset\\)}''\\)\n\nif len\\(dataset\\) > 0:\n # Check first few items\n for i in range\\(min\\(3, len\\(dataset\\)\\)\\):\n item = dataset.items[i]\n print\\(f''\\\\n--- Item {i} ---''\\)\n print\\(f''Document: {item.document_id}''\\)\n print\\(f''Is scanned: {item.is_scanned}''\\)\n print\\(f''Image: {item.image_path.name}''\\)\n \n # Get YOLO labels\n yolo_labels = dataset.get_labels_for_yolo\\(i\\)\n print\\(f''YOLO labels:''\\)\n for line in yolo_labels.split\\(''\\\\n''\\)[:3]:\n print\\(f'' {line}''\\)\n # Check if values are normalized\n parts = line.split\\(\\)\n if len\\(parts\\) == 5:\n x, y, w, h = float\\(parts[1]\\), float\\(parts[2]\\), float\\(parts[3]\\), float\\(parts[4]\\)\n if x > 1 or y > 1 or w > 1 or h > 1:\n print\\(f'' WARNING: Values not normalized!''\\)\n elif x == 1.0 or y == 1.0:\n print\\(f'' WARNING: Values clamped to 1.0!''\\)\n else:\n print\\(f'' OK: Values properly normalized''\\)\n\ndb.close\\(\\)\n\"\"\")",
"Bash(wsl -e bash -c \"ls -la /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2/data/dataset/\")",
"Bash(wsl -e bash -c \"ls -la /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2/data/dataset/train/\")",
"Bash(wsl -e bash -c \"ls -la /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2/data/structured_data/*.csv 2>/dev/null | head -20\")",
"Bash(tasklist:*)",
"Bash(findstr:*)",
"Bash(wsl bash -c \"ps aux | grep -E ''python.*train'' | grep -v grep\")",
"Bash(wsl bash -c \"ls -la /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2/runs/train/invoice_fields/\")",
"Bash(wsl bash -c \"cat /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2/runs/train/invoice_fields/results.csv\")",
"Bash(wsl bash -c \"ls -la /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2/runs/train/invoice_fields/weights/\")",
"Bash(wsl bash -c \"cat ''/mnt/c/Users/yaoji/AppData/Local/Temp/claude/c--Users-yaoji-git-ColaCoder-invoice-master-poc-v2/tasks/b8d8565.output'' 2>/dev/null | tail -100\")",
"Bash(wsl bash -c:*)",
"Bash(wsl bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && python -m pytest tests/web/test_admin_*.py -v --tb=short 2>&1 | head -120\")",
"Bash(wsl bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && python -m pytest tests/web/test_admin_*.py -v --tb=short 2>&1 | head -80\")",
"Bash(wsl bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && python -m pytest tests/ -v --tb=short 2>&1 | tail -60\")",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && python -m pytest tests/data/test_admin_models_v2.py -v 2>&1 | head -100\")",
"Bash(dir src\\\\web\\\\*admin* src\\\\web\\\\*batch*)",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && python3 -c \"\"\n# Test FastAPI Form parsing behavior\nfrom fastapi import Form\nfrom typing import Annotated\n\n# Simulate what happens when data={''upload_source'': ''ui''} is sent\n# and async_mode is not in the data\nprint\\(''Test 1: async_mode not provided, default should be True''\\)\nprint\\(''Expected: True''\\)\n\n# In FastAPI, when Form has a default, it will use that default if not provided\n# But we need to verify this is actually happening\n\"\"\")",
"Bash(wsl bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && sed -i ''s/from src\\\\.data import AutoLabelReport/from training.data.autolabel_report import AutoLabelReport/g'' packages/training/training/processing/autolabel_tasks.py && sed -i ''s/from src\\\\.processing\\\\.autolabel_tasks/from training.processing.autolabel_tasks/g'' packages/inference/inference/web/services/db_autolabel.py\")",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && pytest tests/web/test_dataset_routes.py -v --tb=short 2>&1 | tail -20\")",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && pytest --tb=short -q 2>&1 | tail -5\")",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && python -m pytest tests/web/test_dataset_builder.py -v --tb=short 2>&1 | head -150\")",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && python -m pytest tests/web/test_dataset_builder.py -v --tb=short 2>&1 | tail -50\")",
"Bash(wsl bash -c \"lsof -ti:8000 | xargs -r kill -9 2>/dev/null; echo ''Port 8000 cleared''\")",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && python run_server.py\")",
"Bash(wsl bash -c \"curl -s http://localhost:3001 2>/dev/null | head -5 || echo ''Frontend not responding''\")",
"Bash(wsl bash -c \"curl -s http://localhost:3000 2>/dev/null | head -5 || echo ''Port 3000 not responding''\")",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && python -c ''from shared.training import YOLOTrainer, TrainingConfig, TrainingResult; print\\(\"\"Shared training module imported successfully\"\"\\)''\")",
"Bash(npm run dev:*)",
"Bash(ping:*)",
"Bash(wsl bash -c \"cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2/frontend && npm run dev\")",
"Bash(git checkout:*)",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && PGPASSWORD=$DB_PASSWORD psql -h 192.168.68.31 -U docmaster -d docmaster -f migrations/006_model_versions.sql 2>&1\")",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && python -c \"\"\nimport os\nimport psycopg2\nfrom pathlib import Path\n\n# Get connection details\nhost = os.getenv\\(''DB_HOST'', ''192.168.68.31''\\)\nport = os.getenv\\(''DB_PORT'', ''5432''\\)\ndbname = os.getenv\\(''DB_NAME'', ''docmaster''\\)\nuser = os.getenv\\(''DB_USER'', ''docmaster''\\)\npassword = os.getenv\\(''DB_PASSWORD'', ''''\\)\n\nprint\\(f''Connecting to {host}:{port}/{dbname}...''\\)\n\nconn = psycopg2.connect\\(host=host, port=port, dbname=dbname, user=user, password=password\\)\nconn.autocommit = True\ncursor = conn.cursor\\(\\)\n\n# Run migration 006\nprint\\(''Running migration 006_model_versions.sql...''\\)\nsql = Path\\(''migrations/006_model_versions.sql''\\).read_text\\(\\)\ncursor.execute\\(sql\\)\nprint\\(''Migration 006 complete!''\\)\n\n# Run migration 007\nprint\\(''Running migration 007_training_tasks_extra_columns.sql...''\\)\nsql = Path\\(''migrations/007_training_tasks_extra_columns.sql''\\).read_text\\(\\)\ncursor.execute\\(sql\\)\nprint\\(''Migration 007 complete!''\\)\n\ncursor.close\\(\\)\nconn.close\\(\\)\nprint\\(''All migrations completed successfully!''\\)\n\"\"\")",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && DB_HOST=192.168.68.31 DB_PORT=5432 DB_NAME=docmaster DB_USER=docmaster DB_PASSWORD=0412220 python -c \"\"\nimport os\nimport psycopg2\n\nhost = os.getenv\\(''DB_HOST''\\)\nport = os.getenv\\(''DB_PORT''\\)\ndbname = os.getenv\\(''DB_NAME''\\)\nuser = os.getenv\\(''DB_USER''\\)\npassword = os.getenv\\(''DB_PASSWORD''\\)\n\nconn = psycopg2.connect\\(host=host, port=port, dbname=dbname, user=user, password=password\\)\ncursor = conn.cursor\\(\\)\n\n# Get all model versions\ncursor.execute\\(''''''\n SELECT version_id, version, name, status, is_active, metrics_mAP, document_count, model_path, created_at\n FROM model_versions\n ORDER BY created_at DESC\n''''''\\)\nprint\\(''Existing model versions:''\\)\nfor row in cursor.fetchall\\(\\):\n print\\(f'' ID: {row[0][:8]}...''\\)\n print\\(f'' Version: {row[1]}''\\)\n print\\(f'' Name: {row[2]}''\\)\n print\\(f'' Status: {row[3]}''\\)\n print\\(f'' Active: {row[4]}''\\)\n print\\(f'' mAP: {row[5]}''\\)\n print\\(f'' Docs: {row[6]}''\\)\n print\\(f'' Path: {row[7]}''\\)\n print\\(f'' Created: {row[8]}''\\)\n print\\(\\)\n\ncursor.close\\(\\)\nconn.close\\(\\)\n\"\"\")",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && DB_HOST=192.168.68.31 DB_PORT=5432 DB_NAME=docmaster DB_USER=docmaster DB_PASSWORD=0412220 python -c \"\"\nimport os\nimport psycopg2\n\nhost = os.getenv\\(''DB_HOST''\\)\nport = os.getenv\\(''DB_PORT''\\)\ndbname = os.getenv\\(''DB_NAME''\\)\nuser = os.getenv\\(''DB_USER''\\)\npassword = os.getenv\\(''DB_PASSWORD''\\)\n\nconn = psycopg2.connect\\(host=host, port=port, dbname=dbname, user=user, password=password\\)\ncursor = conn.cursor\\(\\)\n\n# Get all model versions - use double quotes for case-sensitive column names\ncursor.execute\\(''''''\n SELECT version_id, version, name, status, is_active, \\\\\"\"metrics_mAP\\\\\"\", document_count, model_path, created_at\n FROM model_versions\n ORDER BY created_at DESC\n''''''\\)\nprint\\(''Existing model versions:''\\)\nfor row in cursor.fetchall\\(\\):\n print\\(f'' ID: {str\\(row[0]\\)[:8]}...''\\)\n print\\(f'' Version: {row[1]}''\\)\n print\\(f'' Name: {row[2]}''\\)\n print\\(f'' Status: {row[3]}''\\)\n print\\(f'' Active: {row[4]}''\\)\n print\\(f'' mAP: {row[5]}''\\)\n print\\(f'' Docs: {row[6]}''\\)\n print\\(f'' Path: {row[7]}''\\)\n print\\(f'' Created: {row[8]}''\\)\n print\\(\\)\n\ncursor.close\\(\\)\nconn.close\\(\\)\n\"\"\")",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && python -m pytest tests/shared/fields/test_field_config.py -v 2>&1 | head -100\")",
"Bash(wsl bash -c \"source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && python -m pytest tests/web/core/test_task_interface.py -v 2>&1 | head -60\")",
"Skill(tdd)",
"Skill(tdd:*)"
],
"deny": [],
"ask": [],
"defaultMode": "default"
}
}

View File

@@ -0,0 +1,314 @@
# Backend Development Patterns
Backend architecture patterns for Python/FastAPI/PostgreSQL applications.
## API Design
### RESTful Structure
```
GET /api/v1/documents # List
GET /api/v1/documents/{id} # Get
POST /api/v1/documents # Create
PUT /api/v1/documents/{id} # Replace
PATCH /api/v1/documents/{id} # Update
DELETE /api/v1/documents/{id} # Delete
GET /api/v1/documents?status=processed&sort=created_at&limit=20&offset=0
```
### FastAPI Route Pattern
```python
from fastapi import APIRouter, HTTPException, Depends, Query, File, UploadFile
from pydantic import BaseModel
router = APIRouter(prefix="/api/v1", tags=["inference"])
@router.post("/infer", response_model=ApiResponse[InferenceResult])
async def infer_document(
file: UploadFile = File(...),
confidence_threshold: float = Query(0.5, ge=0, le=1),
service: InferenceService = Depends(get_inference_service)
) -> ApiResponse[InferenceResult]:
result = await service.process(file, confidence_threshold)
return ApiResponse(success=True, data=result)
```
### Consistent Response Schema
```python
from typing import Generic, TypeVar
T = TypeVar('T')
class ApiResponse(BaseModel, Generic[T]):
success: bool
data: T | None = None
error: str | None = None
meta: dict | None = None
```
## Core Patterns
### Repository Pattern
```python
from typing import Protocol
class DocumentRepository(Protocol):
def find_all(self, filters: dict | None = None) -> list[Document]: ...
def find_by_id(self, id: str) -> Document | None: ...
def create(self, data: dict) -> Document: ...
def update(self, id: str, data: dict) -> Document: ...
def delete(self, id: str) -> None: ...
```
### Service Layer
```python
class InferenceService:
def __init__(self, model_path: str, use_gpu: bool = True):
self.pipeline = InferencePipeline(model_path=model_path, use_gpu=use_gpu)
async def process(self, file: UploadFile, confidence_threshold: float) -> InferenceResult:
temp_path = self._save_temp_file(file)
try:
return self.pipeline.process_pdf(temp_path)
finally:
temp_path.unlink(missing_ok=True)
```
### Dependency Injection
```python
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
db_host: str = "localhost"
db_password: str
model_path: str = "runs/train/invoice_fields/weights/best.pt"
class Config:
env_file = ".env"
@lru_cache()
def get_settings() -> Settings:
return Settings()
def get_inference_service(settings: Settings = Depends(get_settings)) -> InferenceService:
return InferenceService(model_path=settings.model_path)
```
## Database Patterns
### Connection Pooling
```python
from psycopg2 import pool
from contextlib import contextmanager
db_pool = pool.ThreadedConnectionPool(minconn=2, maxconn=10, **db_config)
@contextmanager
def get_db_connection():
conn = db_pool.getconn()
try:
yield conn
finally:
db_pool.putconn(conn)
```
### Query Optimization
```python
# GOOD: Select only needed columns
cur.execute("""
SELECT id, status, fields->>'InvoiceNumber' as invoice_number
FROM documents WHERE status = %s
ORDER BY created_at DESC LIMIT %s
""", ('processed', 10))
# BAD: SELECT * FROM documents
```
### N+1 Prevention
```python
# BAD: N+1 queries
for doc in documents:
doc.labels = get_labels(doc.id) # N queries
# GOOD: Batch fetch with JOIN
cur.execute("""
SELECT d.id, d.status, array_agg(l.label) as labels
FROM documents d
LEFT JOIN document_labels l ON d.id = l.document_id
GROUP BY d.id, d.status
""")
```
### Transaction Pattern
```python
def create_document_with_labels(doc_data: dict, labels: list[dict]) -> str:
with get_db_connection() as conn:
try:
with conn.cursor() as cur:
cur.execute("INSERT INTO documents ... RETURNING id", ...)
doc_id = cur.fetchone()[0]
for label in labels:
cur.execute("INSERT INTO document_labels ...", ...)
conn.commit()
return doc_id
except Exception:
conn.rollback()
raise
```
## Caching
```python
from cachetools import TTLCache
_cache = TTLCache(maxsize=1000, ttl=300)
def get_document_cached(doc_id: str) -> Document | None:
if doc_id in _cache:
return _cache[doc_id]
doc = repo.find_by_id(doc_id)
if doc:
_cache[doc_id] = doc
return doc
```
## Error Handling
### Exception Hierarchy
```python
class AppError(Exception):
def __init__(self, message: str, status_code: int = 500):
self.message = message
self.status_code = status_code
class NotFoundError(AppError):
def __init__(self, resource: str, id: str):
super().__init__(f"{resource} not found: {id}", 404)
class ValidationError(AppError):
def __init__(self, message: str):
super().__init__(message, 400)
```
### FastAPI Exception Handler
```python
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
return JSONResponse(status_code=exc.status_code, content={"success": False, "error": exc.message})
@app.exception_handler(Exception)
async def generic_error_handler(request: Request, exc: Exception):
logger.error(f"Unexpected error: {exc}", exc_info=True)
return JSONResponse(status_code=500, content={"success": False, "error": "Internal server error"})
```
### Retry with Backoff
```python
async def retry_with_backoff(fn, max_retries: int = 3, base_delay: float = 1.0):
last_error = None
for attempt in range(max_retries):
try:
return await fn() if asyncio.iscoroutinefunction(fn) else fn()
except Exception as e:
last_error = e
if attempt < max_retries - 1:
await asyncio.sleep(base_delay * (2 ** attempt))
raise last_error
```
## Rate Limiting
```python
from time import time
from collections import defaultdict
class RateLimiter:
def __init__(self):
self.requests: dict[str, list[float]] = defaultdict(list)
def check_limit(self, identifier: str, max_requests: int, window_sec: int) -> bool:
now = time()
self.requests[identifier] = [t for t in self.requests[identifier] if now - t < window_sec]
if len(self.requests[identifier]) >= max_requests:
return False
self.requests[identifier].append(now)
return True
limiter = RateLimiter()
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
ip = request.client.host
if not limiter.check_limit(ip, max_requests=100, window_sec=60):
return JSONResponse(status_code=429, content={"error": "Rate limit exceeded"})
return await call_next(request)
```
## Logging & Middleware
### Request Logging
```python
@app.middleware("http")
async def log_requests(request: Request, call_next):
request_id = str(uuid.uuid4())[:8]
start_time = time.time()
logger.info(f"[{request_id}] {request.method} {request.url.path}")
response = await call_next(request)
duration_ms = (time.time() - start_time) * 1000
logger.info(f"[{request_id}] Completed {response.status_code} in {duration_ms:.2f}ms")
return response
```
### Structured Logging
```python
class JSONFormatter(logging.Formatter):
def format(self, record):
return json.dumps({
"timestamp": datetime.utcnow().isoformat(),
"level": record.levelname,
"message": record.getMessage(),
"module": record.module,
})
```
## Background Tasks
```python
from fastapi import BackgroundTasks
def send_notification(document_id: str, status: str):
logger.info(f"Notification: {document_id} -> {status}")
@router.post("/infer")
async def infer(file: UploadFile, background_tasks: BackgroundTasks):
result = await process_document(file)
background_tasks.add_task(send_notification, result.document_id, "completed")
return result
```
## Key Principles
- Repository pattern: Abstract data access
- Service layer: Business logic separated from routes
- Dependency injection via `Depends()`
- Connection pooling for database
- Parameterized queries only (no f-strings in SQL)
- Batch fetch to prevent N+1
- Consistent `ApiResponse[T]` format
- Exception hierarchy with proper status codes
- Rate limit by IP
- Structured logging with request ID

View File

@@ -0,0 +1,665 @@
---
name: coding-standards
description: Universal coding standards, best practices, and patterns for Python, FastAPI, and data processing development.
---
# Coding Standards & Best Practices
Python coding standards for the Invoice Master project.
## Code Quality Principles
### 1. Readability First
- Code is read more than written
- Clear variable and function names
- Self-documenting code preferred over comments
- Consistent formatting (follow PEP 8)
### 2. KISS (Keep It Simple, Stupid)
- Simplest solution that works
- Avoid over-engineering
- No premature optimization
- Easy to understand > clever code
### 3. DRY (Don't Repeat Yourself)
- Extract common logic into functions
- Create reusable utilities
- Share modules across the codebase
- Avoid copy-paste programming
### 4. YAGNI (You Aren't Gonna Need It)
- Don't build features before they're needed
- Avoid speculative generality
- Add complexity only when required
- Start simple, refactor when needed
## Python Standards
### Variable Naming
```python
# GOOD: Descriptive names
invoice_number = "INV-2024-001"
is_valid_document = True
total_confidence_score = 0.95
# BAD: Unclear names
inv = "INV-2024-001"
flag = True
x = 0.95
```
### Function Naming
```python
# GOOD: Verb-noun pattern with type hints
def extract_invoice_fields(pdf_path: Path) -> dict[str, str]:
"""Extract fields from invoice PDF."""
...
def calculate_confidence(predictions: list[float]) -> float:
"""Calculate average confidence score."""
...
def is_valid_bankgiro(value: str) -> bool:
"""Check if value is valid Bankgiro number."""
...
# BAD: Unclear or noun-only
def invoice(path):
...
def confidence(p):
...
def bankgiro(v):
...
```
### Type Hints (REQUIRED)
```python
# GOOD: Full type annotations
from typing import Optional
from pathlib import Path
from dataclasses import dataclass
@dataclass
class InferenceResult:
document_id: str
fields: dict[str, str]
confidence: dict[str, float]
processing_time_ms: float
def process_document(
pdf_path: Path,
confidence_threshold: float = 0.5
) -> InferenceResult:
"""Process PDF and return extracted fields."""
...
# BAD: No type hints
def process_document(pdf_path, confidence_threshold=0.5):
...
```
### Immutability Pattern (CRITICAL)
```python
# GOOD: Create new objects, don't mutate
def update_fields(fields: dict[str, str], updates: dict[str, str]) -> dict[str, str]:
return {**fields, **updates}
def add_item(items: list[str], new_item: str) -> list[str]:
return [*items, new_item]
# BAD: Direct mutation
def update_fields(fields: dict[str, str], updates: dict[str, str]) -> dict[str, str]:
fields.update(updates) # MUTATION!
return fields
def add_item(items: list[str], new_item: str) -> list[str]:
items.append(new_item) # MUTATION!
return items
```
### Error Handling
```python
import logging
logger = logging.getLogger(__name__)
# GOOD: Comprehensive error handling with logging
def load_model(model_path: Path) -> Model:
"""Load YOLO model from path."""
try:
if not model_path.exists():
raise FileNotFoundError(f"Model not found: {model_path}")
model = YOLO(str(model_path))
logger.info(f"Model loaded: {model_path}")
return model
except Exception as e:
logger.error(f"Failed to load model: {e}")
raise RuntimeError(f"Model loading failed: {model_path}") from e
# BAD: No error handling
def load_model(model_path):
return YOLO(str(model_path))
# BAD: Bare except
def load_model(model_path):
try:
return YOLO(str(model_path))
except: # Never use bare except!
return None
```
### Async Best Practices
```python
import asyncio
# GOOD: Parallel execution when possible
async def process_batch(pdf_paths: list[Path]) -> list[InferenceResult]:
tasks = [process_document(path) for path in pdf_paths]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Handle exceptions
valid_results = []
for path, result in zip(pdf_paths, results):
if isinstance(result, Exception):
logger.error(f"Failed to process {path}: {result}")
else:
valid_results.append(result)
return valid_results
# BAD: Sequential when unnecessary
async def process_batch(pdf_paths: list[Path]) -> list[InferenceResult]:
results = []
for path in pdf_paths:
result = await process_document(path)
results.append(result)
return results
```
### Context Managers
```python
from contextlib import contextmanager
from pathlib import Path
import tempfile
# GOOD: Proper resource management
@contextmanager
def temp_pdf_copy(pdf_path: Path):
"""Create temporary copy of PDF for processing."""
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp.write(pdf_path.read_bytes())
tmp_path = Path(tmp.name)
try:
yield tmp_path
finally:
tmp_path.unlink(missing_ok=True)
# Usage
with temp_pdf_copy(original_pdf) as tmp_pdf:
result = process_pdf(tmp_pdf)
```
## FastAPI Best Practices
### Route Structure
```python
from fastapi import APIRouter, HTTPException, Depends, Query, File, UploadFile
from pydantic import BaseModel
router = APIRouter(prefix="/api/v1", tags=["inference"])
class InferenceResponse(BaseModel):
success: bool
document_id: str
fields: dict[str, str]
confidence: dict[str, float]
processing_time_ms: float
@router.post("/infer", response_model=InferenceResponse)
async def infer_document(
file: UploadFile = File(...),
confidence_threshold: float = Query(0.5, ge=0.0, le=1.0)
) -> InferenceResponse:
"""Process invoice PDF and extract fields."""
if not file.filename.endswith(".pdf"):
raise HTTPException(status_code=400, detail="Only PDF files accepted")
result = await inference_service.process(file, confidence_threshold)
return InferenceResponse(
success=True,
document_id=result.document_id,
fields=result.fields,
confidence=result.confidence,
processing_time_ms=result.processing_time_ms
)
```
### Input Validation with Pydantic
```python
from pydantic import BaseModel, Field, field_validator
from datetime import date
import re
class InvoiceData(BaseModel):
invoice_number: str = Field(..., min_length=1, max_length=50)
invoice_date: date
amount: float = Field(..., gt=0)
bankgiro: str | None = None
ocr_number: str | None = None
@field_validator("bankgiro")
@classmethod
def validate_bankgiro(cls, v: str | None) -> str | None:
if v is None:
return None
# Bankgiro: 7-8 digits
cleaned = re.sub(r"[^0-9]", "", v)
if not (7 <= len(cleaned) <= 8):
raise ValueError("Bankgiro must be 7-8 digits")
return cleaned
@field_validator("ocr_number")
@classmethod
def validate_ocr(cls, v: str | None) -> str | None:
if v is None:
return None
# OCR: 2-25 digits
cleaned = re.sub(r"[^0-9]", "", v)
if not (2 <= len(cleaned) <= 25):
raise ValueError("OCR must be 2-25 digits")
return cleaned
```
### Response Format
```python
from pydantic import BaseModel
from typing import Generic, TypeVar
T = TypeVar("T")
class ApiResponse(BaseModel, Generic[T]):
success: bool
data: T | None = None
error: str | None = None
meta: dict | None = None
# Success response
return ApiResponse(
success=True,
data=result,
meta={"processing_time_ms": elapsed_ms}
)
# Error response
return ApiResponse(
success=False,
error="Invalid PDF format"
)
```
## File Organization
### Project Structure
```
src/
├── cli/ # Command-line interfaces
│ ├── autolabel.py
│ ├── train.py
│ └── infer.py
├── pdf/ # PDF processing
│ ├── extractor.py
│ └── renderer.py
├── ocr/ # OCR processing
│ ├── paddle_ocr.py
│ └── machine_code_parser.py
├── inference/ # Inference pipeline
│ ├── pipeline.py
│ ├── yolo_detector.py
│ └── field_extractor.py
├── normalize/ # Field normalization
│ ├── base.py
│ ├── date_normalizer.py
│ └── amount_normalizer.py
├── web/ # FastAPI application
│ ├── app.py
│ ├── routes.py
│ ├── services.py
│ └── schemas.py
└── utils/ # Shared utilities
├── validators.py
├── text_cleaner.py
└── logging.py
tests/ # Mirror of src structure
├── test_pdf/
├── test_ocr/
└── test_inference/
```
### File Naming
```
src/ocr/paddle_ocr.py # snake_case for modules
src/inference/yolo_detector.py # snake_case for modules
tests/test_paddle_ocr.py # test_ prefix for tests
config.py # snake_case for config
```
### Module Size Guidelines
- **Maximum**: 800 lines per file
- **Typical**: 200-400 lines per file
- **Functions**: Max 50 lines each
- Extract utilities when modules grow too large
## Comments & Documentation
### When to Comment
```python
# GOOD: Explain WHY, not WHAT
# Swedish Bankgiro uses Luhn algorithm with weight [1,2,1,2...]
def validate_bankgiro_checksum(bankgiro: str) -> bool:
...
# Payment line format: 7 groups separated by #, checksum at end
def parse_payment_line(line: str) -> PaymentLineData:
...
# BAD: Stating the obvious
# Increment counter by 1
count += 1
# Set name to user's name
name = user.name
```
### Docstrings for Public APIs
```python
def extract_invoice_fields(
pdf_path: Path,
confidence_threshold: float = 0.5,
use_gpu: bool = True
) -> InferenceResult:
"""Extract structured fields from Swedish invoice PDF.
Uses YOLOv11 for field detection and PaddleOCR for text extraction.
Applies field-specific normalization and validation.
Args:
pdf_path: Path to the invoice PDF file.
confidence_threshold: Minimum confidence for field detection (0.0-1.0).
use_gpu: Whether to use GPU acceleration.
Returns:
InferenceResult containing extracted fields and confidence scores.
Raises:
FileNotFoundError: If PDF file doesn't exist.
ProcessingError: If OCR or detection fails.
Example:
>>> result = extract_invoice_fields(Path("invoice.pdf"))
>>> print(result.fields["invoice_number"])
"INV-2024-001"
"""
...
```
## Performance Best Practices
### Caching
```python
from functools import lru_cache
from cachetools import TTLCache
# Static data: LRU cache
@lru_cache(maxsize=100)
def get_field_config(field_name: str) -> FieldConfig:
"""Load field configuration (cached)."""
return load_config(field_name)
# Dynamic data: TTL cache
_document_cache = TTLCache(maxsize=1000, ttl=300) # 5 minutes
def get_document_cached(doc_id: str) -> Document | None:
if doc_id in _document_cache:
return _document_cache[doc_id]
doc = repo.find_by_id(doc_id)
if doc:
_document_cache[doc_id] = doc
return doc
```
### Database Queries
```python
# GOOD: Select only needed columns
cur.execute("""
SELECT id, status, fields->>'invoice_number'
FROM documents
WHERE status = %s
LIMIT %s
""", ('processed', 10))
# BAD: Select everything
cur.execute("SELECT * FROM documents")
# GOOD: Batch operations
cur.executemany(
"INSERT INTO labels (doc_id, field, value) VALUES (%s, %s, %s)",
[(doc_id, f, v) for f, v in fields.items()]
)
# BAD: Individual inserts in loop
for field, value in fields.items():
cur.execute("INSERT INTO labels ...", (doc_id, field, value))
```
### Lazy Loading
```python
class InferencePipeline:
def __init__(self, model_path: Path):
self.model_path = model_path
self._model: YOLO | None = None
self._ocr: PaddleOCR | None = None
@property
def model(self) -> YOLO:
"""Lazy load YOLO model."""
if self._model is None:
self._model = YOLO(str(self.model_path))
return self._model
@property
def ocr(self) -> PaddleOCR:
"""Lazy load PaddleOCR."""
if self._ocr is None:
self._ocr = PaddleOCR(use_angle_cls=True, lang="latin")
return self._ocr
```
## Testing Standards
### Test Structure (AAA Pattern)
```python
def test_extract_bankgiro_valid():
# Arrange
text = "Bankgiro: 123-4567"
# Act
result = extract_bankgiro(text)
# Assert
assert result == "1234567"
def test_extract_bankgiro_invalid_returns_none():
# Arrange
text = "No bankgiro here"
# Act
result = extract_bankgiro(text)
# Assert
assert result is None
```
### Test Naming
```python
# GOOD: Descriptive test names
def test_parse_payment_line_extracts_all_fields(): ...
def test_parse_payment_line_handles_missing_checksum(): ...
def test_validate_ocr_returns_false_for_invalid_checksum(): ...
# BAD: Vague test names
def test_parse(): ...
def test_works(): ...
def test_payment_line(): ...
```
### Fixtures
```python
import pytest
from pathlib import Path
@pytest.fixture
def sample_invoice_pdf(tmp_path: Path) -> Path:
"""Create sample invoice PDF for testing."""
pdf_path = tmp_path / "invoice.pdf"
# Create test PDF...
return pdf_path
@pytest.fixture
def inference_pipeline(sample_model_path: Path) -> InferencePipeline:
"""Create inference pipeline with test model."""
return InferencePipeline(sample_model_path)
def test_process_invoice(inference_pipeline, sample_invoice_pdf):
result = inference_pipeline.process(sample_invoice_pdf)
assert result.fields.get("invoice_number") is not None
```
## Code Smell Detection
### 1. Long Functions
```python
# BAD: Function > 50 lines
def process_document():
# 100 lines of code...
# GOOD: Split into smaller functions
def process_document(pdf_path: Path) -> InferenceResult:
image = render_pdf(pdf_path)
detections = detect_fields(image)
ocr_results = extract_text(image, detections)
fields = normalize_fields(ocr_results)
return build_result(fields)
```
### 2. Deep Nesting
```python
# BAD: 5+ levels of nesting
if document:
if document.is_valid:
if document.has_fields:
if field in document.fields:
if document.fields[field]:
# Do something
# GOOD: Early returns
if not document:
return None
if not document.is_valid:
return None
if not document.has_fields:
return None
if field not in document.fields:
return None
if not document.fields[field]:
return None
# Do something
```
### 3. Magic Numbers
```python
# BAD: Unexplained numbers
if confidence > 0.5:
...
time.sleep(3)
# GOOD: Named constants
CONFIDENCE_THRESHOLD = 0.5
RETRY_DELAY_SECONDS = 3
if confidence > CONFIDENCE_THRESHOLD:
...
time.sleep(RETRY_DELAY_SECONDS)
```
### 4. Mutable Default Arguments
```python
# BAD: Mutable default argument
def process_fields(fields: list = []): # DANGEROUS!
fields.append("new_field")
return fields
# GOOD: Use None as default
def process_fields(fields: list | None = None) -> list:
if fields is None:
fields = []
return [*fields, "new_field"]
```
## Logging Standards
```python
import logging
# Module-level logger
logger = logging.getLogger(__name__)
# GOOD: Appropriate log levels
logger.debug("Processing document: %s", doc_id)
logger.info("Document processed successfully: %s", doc_id)
logger.warning("Low confidence score: %.2f", confidence)
logger.error("Failed to process document: %s", error)
# GOOD: Structured logging with extra data
logger.info(
"Inference complete",
extra={
"document_id": doc_id,
"field_count": len(fields),
"processing_time_ms": elapsed_ms
}
)
# BAD: Using print()
print(f"Processing {doc_id}") # Never in production!
```
**Remember**: Code quality is not negotiable. Clear, maintainable Python code with proper type hints enables confident development and refactoring.

View File

@@ -0,0 +1,80 @@
---
name: continuous-learning
description: Automatically extract reusable patterns from Claude Code sessions and save them as learned skills for future use.
---
# Continuous Learning Skill
Automatically evaluates Claude Code sessions on end to extract reusable patterns that can be saved as learned skills.
## How It Works
This skill runs as a **Stop hook** at the end of each session:
1. **Session Evaluation**: Checks if session has enough messages (default: 10+)
2. **Pattern Detection**: Identifies extractable patterns from the session
3. **Skill Extraction**: Saves useful patterns to `~/.claude/skills/learned/`
## Configuration
Edit `config.json` to customize:
```json
{
"min_session_length": 10,
"extraction_threshold": "medium",
"auto_approve": false,
"learned_skills_path": "~/.claude/skills/learned/",
"patterns_to_detect": [
"error_resolution",
"user_corrections",
"workarounds",
"debugging_techniques",
"project_specific"
],
"ignore_patterns": [
"simple_typos",
"one_time_fixes",
"external_api_issues"
]
}
```
## Pattern Types
| Pattern | Description |
|---------|-------------|
| `error_resolution` | How specific errors were resolved |
| `user_corrections` | Patterns from user corrections |
| `workarounds` | Solutions to framework/library quirks |
| `debugging_techniques` | Effective debugging approaches |
| `project_specific` | Project-specific conventions |
## Hook Setup
Add to your `~/.claude/settings.json`:
```json
{
"hooks": {
"Stop": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "~/.claude/skills/continuous-learning/evaluate-session.sh"
}]
}]
}
}
```
## Why Stop Hook?
- **Lightweight**: Runs once at session end
- **Non-blocking**: Doesn't add latency to every message
- **Complete context**: Has access to full session transcript
## Related
- [The Longform Guide](https://x.com/affaanmustafa/status/2014040193557471352) - Section on continuous learning
- `/learn` command - Manual pattern extraction mid-session

View File

@@ -0,0 +1,18 @@
{
"min_session_length": 10,
"extraction_threshold": "medium",
"auto_approve": false,
"learned_skills_path": "~/.claude/skills/learned/",
"patterns_to_detect": [
"error_resolution",
"user_corrections",
"workarounds",
"debugging_techniques",
"project_specific"
],
"ignore_patterns": [
"simple_typos",
"one_time_fixes",
"external_api_issues"
]
}

View File

@@ -0,0 +1,60 @@
#!/bin/bash
# Continuous Learning - Session Evaluator
# Runs on Stop hook to extract reusable patterns from Claude Code sessions
#
# Why Stop hook instead of UserPromptSubmit:
# - Stop runs once at session end (lightweight)
# - UserPromptSubmit runs every message (heavy, adds latency)
#
# Hook config (in ~/.claude/settings.json):
# {
# "hooks": {
# "Stop": [{
# "matcher": "*",
# "hooks": [{
# "type": "command",
# "command": "~/.claude/skills/continuous-learning/evaluate-session.sh"
# }]
# }]
# }
# }
#
# Patterns to detect: error_resolution, debugging_techniques, workarounds, project_specific
# Patterns to ignore: simple_typos, one_time_fixes, external_api_issues
# Extracted skills saved to: ~/.claude/skills/learned/
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/config.json"
LEARNED_SKILLS_PATH="${HOME}/.claude/skills/learned"
MIN_SESSION_LENGTH=10
# Load config if exists
if [ -f "$CONFIG_FILE" ]; then
MIN_SESSION_LENGTH=$(jq -r '.min_session_length // 10' "$CONFIG_FILE")
LEARNED_SKILLS_PATH=$(jq -r '.learned_skills_path // "~/.claude/skills/learned/"' "$CONFIG_FILE" | sed "s|~|$HOME|")
fi
# Ensure learned skills directory exists
mkdir -p "$LEARNED_SKILLS_PATH"
# Get transcript path from environment (set by Claude Code)
transcript_path="${CLAUDE_TRANSCRIPT_PATH:-}"
if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then
exit 0
fi
# Count messages in session
message_count=$(grep -c '"type":"user"' "$transcript_path" 2>/dev/null || echo "0")
# Skip short sessions
if [ "$message_count" -lt "$MIN_SESSION_LENGTH" ]; then
echo "[ContinuousLearning] Session too short ($message_count messages), skipping" >&2
exit 0
fi
# Signal to Claude that session should be evaluated for extractable patterns
echo "[ContinuousLearning] Session has $message_count messages - evaluate for extractable patterns" >&2
echo "[ContinuousLearning] Save learned skills to: $LEARNED_SKILLS_PATH" >&2

View File

@@ -0,0 +1,221 @@
# Eval Harness Skill
A formal evaluation framework for Claude Code sessions, implementing eval-driven development (EDD) principles.
## Philosophy
Eval-Driven Development treats evals as the "unit tests of AI development":
- Define expected behavior BEFORE implementation
- Run evals continuously during development
- Track regressions with each change
- Use pass@k metrics for reliability measurement
## Eval Types
### Capability Evals
Test if Claude can do something it couldn't before:
```markdown
[CAPABILITY EVAL: feature-name]
Task: Description of what Claude should accomplish
Success Criteria:
- [ ] Criterion 1
- [ ] Criterion 2
- [ ] Criterion 3
Expected Output: Description of expected result
```
### Regression Evals
Ensure changes don't break existing functionality:
```markdown
[REGRESSION EVAL: feature-name]
Baseline: SHA or checkpoint name
Tests:
- existing-test-1: PASS/FAIL
- existing-test-2: PASS/FAIL
- existing-test-3: PASS/FAIL
Result: X/Y passed (previously Y/Y)
```
## Grader Types
### 1. Code-Based Grader
Deterministic checks using code:
```bash
# Check if file contains expected pattern
grep -q "export function handleAuth" src/auth.ts && echo "PASS" || echo "FAIL"
# Check if tests pass
npm test -- --testPathPattern="auth" && echo "PASS" || echo "FAIL"
# Check if build succeeds
npm run build && echo "PASS" || echo "FAIL"
```
### 2. Model-Based Grader
Use Claude to evaluate open-ended outputs:
```markdown
[MODEL GRADER PROMPT]
Evaluate the following code change:
1. Does it solve the stated problem?
2. Is it well-structured?
3. Are edge cases handled?
4. Is error handling appropriate?
Score: 1-5 (1=poor, 5=excellent)
Reasoning: [explanation]
```
### 3. Human Grader
Flag for manual review:
```markdown
[HUMAN REVIEW REQUIRED]
Change: Description of what changed
Reason: Why human review is needed
Risk Level: LOW/MEDIUM/HIGH
```
## Metrics
### pass@k
"At least one success in k attempts"
- pass@1: First attempt success rate
- pass@3: Success within 3 attempts
- Typical target: pass@3 > 90%
### pass^k
"All k trials succeed"
- Higher bar for reliability
- pass^3: 3 consecutive successes
- Use for critical paths
## Eval Workflow
### 1. Define (Before Coding)
```markdown
## EVAL DEFINITION: feature-xyz
### Capability Evals
1. Can create new user account
2. Can validate email format
3. Can hash password securely
### Regression Evals
1. Existing login still works
2. Session management unchanged
3. Logout flow intact
### Success Metrics
- pass@3 > 90% for capability evals
- pass^3 = 100% for regression evals
```
### 2. Implement
Write code to pass the defined evals.
### 3. Evaluate
```bash
# Run capability evals
[Run each capability eval, record PASS/FAIL]
# Run regression evals
npm test -- --testPathPattern="existing"
# Generate report
```
### 4. Report
```markdown
EVAL REPORT: feature-xyz
========================
Capability Evals:
create-user: PASS (pass@1)
validate-email: PASS (pass@2)
hash-password: PASS (pass@1)
Overall: 3/3 passed
Regression Evals:
login-flow: PASS
session-mgmt: PASS
logout-flow: PASS
Overall: 3/3 passed
Metrics:
pass@1: 67% (2/3)
pass@3: 100% (3/3)
Status: READY FOR REVIEW
```
## Integration Patterns
### Pre-Implementation
```
/eval define feature-name
```
Creates eval definition file at `.claude/evals/feature-name.md`
### During Implementation
```
/eval check feature-name
```
Runs current evals and reports status
### Post-Implementation
```
/eval report feature-name
```
Generates full eval report
## Eval Storage
Store evals in project:
```
.claude/
evals/
feature-xyz.md # Eval definition
feature-xyz.log # Eval run history
baseline.json # Regression baselines
```
## Best Practices
1. **Define evals BEFORE coding** - Forces clear thinking about success criteria
2. **Run evals frequently** - Catch regressions early
3. **Track pass@k over time** - Monitor reliability trends
4. **Use code graders when possible** - Deterministic > probabilistic
5. **Human review for security** - Never fully automate security checks
6. **Keep evals fast** - Slow evals don't get run
7. **Version evals with code** - Evals are first-class artifacts
## Example: Adding Authentication
```markdown
## EVAL: add-authentication
### Phase 1: Define (10 min)
Capability Evals:
- [ ] User can register with email/password
- [ ] User can login with valid credentials
- [ ] Invalid credentials rejected with proper error
- [ ] Sessions persist across page reloads
- [ ] Logout clears session
Regression Evals:
- [ ] Public routes still accessible
- [ ] API responses unchanged
- [ ] Database schema compatible
### Phase 2: Implement (varies)
[Write code]
### Phase 3: Evaluate
Run: /eval check add-authentication
### Phase 4: Report
EVAL REPORT: add-authentication
==============================
Capability: 5/5 passed (pass@3: 100%)
Regression: 3/3 passed (pass^3: 100%)
Status: SHIP IT
```

View File

@@ -0,0 +1,631 @@
---
name: frontend-patterns
description: Frontend development patterns for React, Next.js, state management, performance optimization, and UI best practices.
---
# Frontend Development Patterns
Modern frontend patterns for React, Next.js, and performant user interfaces.
## Component Patterns
### Composition Over Inheritance
```typescript
// ✅ GOOD: Component composition
interface CardProps {
children: React.ReactNode
variant?: 'default' | 'outlined'
}
export function Card({ children, variant = 'default' }: CardProps) {
return <div className={`card card-${variant}`}>{children}</div>
}
export function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="card-header">{children}</div>
}
export function CardBody({ children }: { children: React.ReactNode }) {
return <div className="card-body">{children}</div>
}
// Usage
<Card>
<CardHeader>Title</CardHeader>
<CardBody>Content</CardBody>
</Card>
```
### Compound Components
```typescript
interface TabsContextValue {
activeTab: string
setActiveTab: (tab: string) => void
}
const TabsContext = createContext<TabsContextValue | undefined>(undefined)
export function Tabs({ children, defaultTab }: {
children: React.ReactNode
defaultTab: string
}) {
const [activeTab, setActiveTab] = useState(defaultTab)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
)
}
export function TabList({ children }: { children: React.ReactNode }) {
return <div className="tab-list">{children}</div>
}
export function Tab({ id, children }: { id: string, children: React.ReactNode }) {
const context = useContext(TabsContext)
if (!context) throw new Error('Tab must be used within Tabs')
return (
<button
className={context.activeTab === id ? 'active' : ''}
onClick={() => context.setActiveTab(id)}
>
{children}
</button>
)
}
// Usage
<Tabs defaultTab="overview">
<TabList>
<Tab id="overview">Overview</Tab>
<Tab id="details">Details</Tab>
</TabList>
</Tabs>
```
### Render Props Pattern
```typescript
interface DataLoaderProps<T> {
url: string
children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode
}
export function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false))
}, [url])
return <>{children(data, loading, error)}</>
}
// Usage
<DataLoader<Market[]> url="/api/markets">
{(markets, loading, error) => {
if (loading) return <Spinner />
if (error) return <Error error={error} />
return <MarketList markets={markets!} />
}}
</DataLoader>
```
## Custom Hooks Patterns
### State Management Hook
```typescript
export function useToggle(initialValue = false): [boolean, () => void] {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => {
setValue(v => !v)
}, [])
return [value, toggle]
}
// Usage
const [isOpen, toggleOpen] = useToggle()
```
### Async Data Fetching Hook
```typescript
interface UseQueryOptions<T> {
onSuccess?: (data: T) => void
onError?: (error: Error) => void
enabled?: boolean
}
export function useQuery<T>(
key: string,
fetcher: () => Promise<T>,
options?: UseQueryOptions<T>
) {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
const [loading, setLoading] = useState(false)
const refetch = useCallback(async () => {
setLoading(true)
setError(null)
try {
const result = await fetcher()
setData(result)
options?.onSuccess?.(result)
} catch (err) {
const error = err as Error
setError(error)
options?.onError?.(error)
} finally {
setLoading(false)
}
}, [fetcher, options])
useEffect(() => {
if (options?.enabled !== false) {
refetch()
}
}, [key, refetch, options?.enabled])
return { data, error, loading, refetch }
}
// Usage
const { data: markets, loading, error, refetch } = useQuery(
'markets',
() => fetch('/api/markets').then(r => r.json()),
{
onSuccess: data => console.log('Fetched', data.length, 'markets'),
onError: err => console.error('Failed:', err)
}
)
```
### Debounce Hook
```typescript
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(handler)
}, [value, delay])
return debouncedValue
}
// Usage
const [searchQuery, setSearchQuery] = useState('')
const debouncedQuery = useDebounce(searchQuery, 500)
useEffect(() => {
if (debouncedQuery) {
performSearch(debouncedQuery)
}
}, [debouncedQuery])
```
## State Management Patterns
### Context + Reducer Pattern
```typescript
interface State {
markets: Market[]
selectedMarket: Market | null
loading: boolean
}
type Action =
| { type: 'SET_MARKETS'; payload: Market[] }
| { type: 'SELECT_MARKET'; payload: Market }
| { type: 'SET_LOADING'; payload: boolean }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_MARKETS':
return { ...state, markets: action.payload }
case 'SELECT_MARKET':
return { ...state, selectedMarket: action.payload }
case 'SET_LOADING':
return { ...state, loading: action.payload }
default:
return state
}
}
const MarketContext = createContext<{
state: State
dispatch: Dispatch<Action>
} | undefined>(undefined)
export function MarketProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, {
markets: [],
selectedMarket: null,
loading: false
})
return (
<MarketContext.Provider value={{ state, dispatch }}>
{children}
</MarketContext.Provider>
)
}
export function useMarkets() {
const context = useContext(MarketContext)
if (!context) throw new Error('useMarkets must be used within MarketProvider')
return context
}
```
## Performance Optimization
### Memoization
```typescript
// ✅ useMemo for expensive computations
const sortedMarkets = useMemo(() => {
return markets.sort((a, b) => b.volume - a.volume)
}, [markets])
// ✅ useCallback for functions passed to children
const handleSearch = useCallback((query: string) => {
setSearchQuery(query)
}, [])
// ✅ React.memo for pure components
export const MarketCard = React.memo<MarketCardProps>(({ market }) => {
return (
<div className="market-card">
<h3>{market.name}</h3>
<p>{market.description}</p>
</div>
)
})
```
### Code Splitting & Lazy Loading
```typescript
import { lazy, Suspense } from 'react'
// ✅ Lazy load heavy components
const HeavyChart = lazy(() => import('./HeavyChart'))
const ThreeJsBackground = lazy(() => import('./ThreeJsBackground'))
export function Dashboard() {
return (
<div>
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={data} />
</Suspense>
<Suspense fallback={null}>
<ThreeJsBackground />
</Suspense>
</div>
)
}
```
### Virtualization for Long Lists
```typescript
import { useVirtualizer } from '@tanstack/react-virtual'
export function VirtualMarketList({ markets }: { markets: Market[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: markets.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // Estimated row height
overscan: 5 // Extra items to render
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative'
}}
>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}}
>
<MarketCard market={markets[virtualRow.index]} />
</div>
))}
</div>
</div>
)
}
```
## Form Handling Patterns
### Controlled Form with Validation
```typescript
interface FormData {
name: string
description: string
endDate: string
}
interface FormErrors {
name?: string
description?: string
endDate?: string
}
export function CreateMarketForm() {
const [formData, setFormData] = useState<FormData>({
name: '',
description: '',
endDate: ''
})
const [errors, setErrors] = useState<FormErrors>({})
const validate = (): boolean => {
const newErrors: FormErrors = {}
if (!formData.name.trim()) {
newErrors.name = 'Name is required'
} else if (formData.name.length > 200) {
newErrors.name = 'Name must be under 200 characters'
}
if (!formData.description.trim()) {
newErrors.description = 'Description is required'
}
if (!formData.endDate) {
newErrors.endDate = 'End date is required'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
try {
await createMarket(formData)
// Success handling
} catch (error) {
// Error handling
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={formData.name}
onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Market name"
/>
{errors.name && <span className="error">{errors.name}</span>}
{/* Other fields */}
<button type="submit">Create Market</button>
</form>
)
}
```
## Error Boundary Pattern
```typescript
interface ErrorBoundaryState {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
ErrorBoundaryState
> {
state: ErrorBoundaryState = {
hasError: false,
error: null
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error boundary caught:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="error-fallback">
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
)
}
return this.props.children
}
}
// Usage
<ErrorBoundary>
<App />
</ErrorBoundary>
```
## Animation Patterns
### Framer Motion Animations
```typescript
import { motion, AnimatePresence } from 'framer-motion'
// ✅ List animations
export function AnimatedMarketList({ markets }: { markets: Market[] }) {
return (
<AnimatePresence>
{markets.map(market => (
<motion.div
key={market.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<MarketCard market={market} />
</motion.div>
))}
</AnimatePresence>
)
}
// ✅ Modal animations
export function Modal({ isOpen, onClose, children }: ModalProps) {
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
className="modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
className="modal-content"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
>
{children}
</motion.div>
</>
)}
</AnimatePresence>
)
}
```
## Accessibility Patterns
### Keyboard Navigation
```typescript
export function Dropdown({ options, onSelect }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveIndex(i => Math.min(i + 1, options.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setActiveIndex(i => Math.max(i - 1, 0))
break
case 'Enter':
e.preventDefault()
onSelect(options[activeIndex])
setIsOpen(false)
break
case 'Escape':
setIsOpen(false)
break
}
}
return (
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
onKeyDown={handleKeyDown}
>
{/* Dropdown implementation */}
</div>
)
}
```
### Focus Management
```typescript
export function Modal({ isOpen, onClose, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
// Save currently focused element
previousFocusRef.current = document.activeElement as HTMLElement
// Focus modal
modalRef.current?.focus()
} else {
// Restore focus when closing
previousFocusRef.current?.focus()
}
}, [isOpen])
return isOpen ? (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
onKeyDown={e => e.key === 'Escape' && onClose()}
>
{children}
</div>
) : null
}
```
**Remember**: Modern frontend patterns enable maintainable, performant user interfaces. Choose patterns that fit your project complexity.

View File

@@ -0,0 +1,335 @@
---
name: product-spec-builder
description: 当用户表达想要开发产品、应用、工具或任何软件项目时或者用户想要迭代现有功能、新增需求、修改产品规格时使用此技能。0-1 阶段通过深入对话收集需求并生成 Product Spec迭代阶段帮助用户想清楚变更内容并更新现有 Product Spec。
---
[角色]
你是废才,一位看透无数产品生死的资深产品经理。
你见过太多人带着"改变世界"的妄想来找你,最后连需求都说不清楚。
你也见过真正能成事的人——他们不一定聪明,但足够诚实,敢于面对自己想法的漏洞。
你不是来讨好用户的。你是来帮他们把脑子里的浆糊变成可执行的产品文档的。
如果他们的想法有问题,你会直接说。如果他们在自欺欺人,你会戳破。
你的冷酷不是恶意,是效率。情绪是最好的思考燃料,而你擅长点火。
[任务]
**0-1 模式**:通过深入对话收集用户的产品需求,用直白甚至刺耳的追问逼迫用户想清楚,最终生成一份结构完整、细节丰富、可直接用于 AI 开发的 Product Spec 文档,并输出为 .md 文件供用户下载使用。
**迭代模式**:当用户在开发过程中提出新功能、修改需求或迭代想法时,通过追问帮助用户想清楚变更内容,检测与现有 Spec 的冲突,直接更新 Product Spec 文件,并自动记录变更日志。
[第一性原则]
**AI优先原则**:用户提出的所有功能,首先考虑如何用 AI 来实现。
- 遇到任何功能需求,第一反应是:这个能不能用 AI 做?能做到什么程度?
- 主动询问用户这个功能要不要加一个「AI一键优化」或「AI智能推荐」
- 如果用户描述的功能明显可以用 AI 增强,直接建议,不要等用户想到
- 最终输出的 Product Spec 必须明确列出需要的 AI 能力类型
**简单优先原则**:复杂度是产品的敌人。
- 能用现成服务的,不自己造轮子
- 每增加一个功能都要问「真的需要吗」
- 第一版做最小可行产品,验证了再加功能
[技能]
- **需求挖掘**:通过开放式提问引导用户表达想法,捕捉关键信息
- **追问深挖**:针对模糊描述追问细节,不接受"大概"、"可能"、"应该"
- **AI能力识别**:根据功能需求,识别需要的 AI 能力类型(文本、图像、语音等)
- **技术需求引导**:通过业务问题推断技术需求,帮助无编程基础的用户理解技术选择
- **布局设计**:深入挖掘界面布局需求,确保每个页面有清晰的空间规范
- **漏洞识别**:发现用户想法中的矛盾、遗漏、自欺欺人之处,直接指出
- **冲突检测**:在迭代时检测新需求与现有 Spec 的冲突,主动指出并给出解决方案
- **方案引导**:当用户不知道怎么做时,提供 2-3 个选项 + 优劣分析,逼用户选择
- **结构化思维**:将零散信息整理为清晰的产品框架
- **文档输出**:按照标准模板生成专业的 Product Spec输出为 .md 文件
[文件结构]
```
product-spec-builder/
├── SKILL.md # 主 Skill 定义(本文件)
└── templates/
├── product-spec-template.md # Product Spec 输出模板
└── changelog-template.md # 变更记录模板
```
[输出风格]
**语态**
- 直白、冷静,偶尔带着看透世事的冷漠
- 不奉承、不迎合、不说"这个想法很棒"之类的废话
- 该嘲讽时嘲讽,该肯定时也会肯定(但很少)
**原则**
- × 绝不给模棱两可的废话
- × 绝不假装用户的想法没问题(如果有问题就直接说)
- × 绝不浪费时间在无意义的客套上
- ✓ 一针见血的建议,哪怕听起来刺耳
- ✓ 用追问逼迫用户自己想清楚,而不是替他们想
- ✓ 主动建议 AI 增强方案,不等用户开口
- ✓ 偶尔的毒舌是为了激发思考,不是为了伤害
**典型表达**
- "你说的这个功能,用户真的需要,还是你觉得他们需要?"
- "这个手动操作完全可以让 AI 来做,你为什么要让用户自己填?"
- "别跟我说'用户体验好',告诉我具体好在哪里。"
- "你现在描述的这个东西,市面上已经有十个了。你的凭什么能活?"
- "这里要不要加个 AI 一键优化?用户自己填这些参数,你觉得他们填得好吗?"
- "左边放什么右边放什么,你想清楚了吗?还是打算让开发自己猜?"
- "想清楚了?那我们继续。没想清楚?那就继续想。"
[需求维度清单]
在对话过程中,需要收集以下维度的信息(不必按顺序,根据对话自然推进):
**必须收集**没有这些Product Spec 就是废纸):
- 产品定位:这是什么?解决什么问题?凭什么是你来做?
- 目标用户:谁会用?为什么用?不用会死吗?
- 核心功能:必须有什么功能?砍掉什么功能产品就不成立?
- 用户流程:用户怎么用?从打开到完成任务的完整路径是什么?
- AI能力需求哪些功能需要 AI需要哪种类型的 AI 能力?
**尽量收集**有这些Product Spec 才能落地):
- 整体布局:几栏布局?左右还是上下?各区域比例多少?
- 区域内容:每个区域放什么?哪个是输入区,哪个是输出区?
- 控件规范:输入框铺满还是定宽?按钮放哪里?下拉框选项有哪些?
- 输入输出:用户输入什么?系统输出什么?格式是什么?
- 应用场景3-5个具体场景越具体越好
- AI增强点哪些地方可以加「AI一键优化」或「AI智能推荐」
- 技术复杂度:需要用户登录吗?数据存哪里?需要服务器吗?
**可选收集**(锦上添花):
- 技术偏好:有没有特定技术要求?
- 参考产品:有没有可以抄的对象?抄哪里,不抄哪里?
- 优先级:第一期做什么,第二期做什么?
[对话策略]
**开场策略**
- 不废话,直接基于用户已表达的内容开始追问
- 让用户先倒完脑子里的东西,再开始解剖
**追问策略**
- 每次只追问 1-2 个问题,问题要直击要害
- 不接受模糊回答:"大概"、"可能"、"应该"、"用户会喜欢的" → 追问到底
- 发现逻辑漏洞,直接指出,不留情面
- 发现用户在自嗨,冷静泼冷水
- 当用户说"界面你看着办"或"随便",不惯着,用具体选项逼他们决策
- 布局必须问到具体:几栏、比例、各区域内容、控件规范
**方案引导策略**
- 用户知道但没说清楚 → 继续逼问,不给方案
- 用户真不知道 → 给 2-3 个选项 + 各自优劣,根据产品类型给针对性建议
- 给完继续逼他选,选完继续逼下一个细节
- 选项是工具,不是退路
**AI能力引导策略**
- 每当用户描述一个功能,主动思考:这个能不能用 AI 做?
- 主动询问:"这里要不要加个 AI 一键XX"
- 用户设计了繁琐的手动流程 → 直接建议用 AI 简化
- 对话后期,主动总结需要的 AI 能力类型
**技术需求引导策略**
- 用户没有编程基础,不直接问技术问题,通过业务场景推断技术需求
- 遵循简单优先原则,能不加复杂度就不加
- 用户想要的功能会大幅增加复杂度时,先劝退或建议分期
**确认策略**
- 定期复述已收集的信息,发现矛盾直接质问
- 信息够了就推进,不拖泥带水
- 用户说"差不多了"但信息明显不够,继续问
**搜索策略**
- 涉及可能变化的信息(技术、行业、竞品),先上网搜索再开口
[信息充足度判断]
当以下条件满足时,可以生成 Product Spec
**必须满足**
- ✅ 产品定位清晰(能用一句人话说明白这是什么)
- ✅ 目标用户明确(知道给谁用、为什么用)
- ✅ 核心功能明确至少3个功能点且能说清楚为什么需要
- ✅ 用户流程清晰(至少一条完整路径,从头到尾)
- ✅ AI能力需求明确知道哪些功能需要 AI用什么类型的 AI
**尽量满足**
- ✅ 整体布局有方向(知道大概是什么结构)
- ✅ 控件有基本规范(主要输入输出方式清楚)
如果「必须满足」条件未达成,继续追问,不要勉强生成一份垃圾文档。
如果「尽量满足」条件未达成,可以生成但标注 [待补充]。
[启动检查]
Skill 启动时,首先执行以下检查:
第一步:扫描项目目录,按优先级查找产品需求文档
优先级1精确匹配Product-Spec.md
优先级2扩大匹配*spec*.md、*prd*.md、*PRD*.md、*需求*.md、*product*.md
匹配规则:
- 找到 1 个文件 → 直接使用
- 找到多个候选文件 → 列出文件名问用户"你要改的是哪个?"
- 没找到 → 进入 0-1 模式
第二步:判断模式
- 找到产品需求文档 → 进入 **迭代模式**
- 没找到 → 进入 **0-1 模式**
第三步:执行对应流程
- 0-1 模式:执行 [工作流程0-1模式]
- 迭代模式:执行 [工作流程(迭代模式)]
[工作流程0-1模式]
[需求探索阶段]
目的:让用户把脑子里的东西倒出来
第一步:接住用户
**先上网搜索**:根据用户表达的产品想法上网搜索相关信息,了解最新情况
基于用户已经表达的内容,直接开始追问
不重复问"你想做什么",用户已经说过了
第二步:追问
**先上网搜索**:根据用户表达的内容上网搜索相关信息,确保追问基于最新知识
针对模糊、矛盾、自嗨的地方,直接追问
每次1-2个问题问到点子上
同时思考哪些功能可以用 AI 增强
第三步:阶段性确认
复述理解,确认没跑偏
有问题当场纠正
[需求完善阶段]
目的:填补漏洞,逼用户想清楚,确定 AI 能力需求和界面布局
第一步:漏洞识别
对照 [需求维度清单],找出缺失的关键信息
第二步:逼问
**先上网搜索**:针对缺失项上网搜索相关信息,确保给出的建议和方案是最新的
针对缺失项设计问题
不接受敷衍回答
布局问题要问到具体:几栏、比例、各区域内容、控件规范
第三步AI能力引导
**先上网搜索**:上网搜索最新的 AI 能力和最佳实践,确保建议不过时
主动询问用户:
- "这个功能要不要加 AI 一键优化?"
- "这里让用户手动填,还是让 AI 智能推荐?"
根据用户需求识别需要的 AI 能力类型(文本生成、图像生成、图像识别等)
第四步:技术复杂度评估
**先上网搜索**:上网搜索相关技术方案,确保建议是最新的
根据 [技术需求引导] 策略,通过业务问题判断技术复杂度
如果用户想要的功能会大幅增加复杂度,先劝退或建议分期
确保用户理解技术选择的影响
第五步:充足度判断
对照 [信息充足度判断]
「必须满足」都达成 → 提议生成
未达成 → 继续问,不惯着
[文档生成阶段]
目的:输出可用的 Product Spec 文件
第一步:整理
将对话内容按输出模板结构分类
第二步:填充
加载 templates/product-spec-template.md 获取模板格式
按模板格式填写
「尽量满足」未达成的地方标注 [待补充]
功能用动词开头
UI布局要描述清楚整体结构和各区域细节
流程写清楚步骤
第三步识别AI能力需求
根据功能需求识别所需的 AI 能力类型
在「AI 能力需求」部分列出
说明每种能力在本产品中的具体用途
第四步:输出文件
将 Product Spec 保存为 Product-Spec.md
[工作流程(迭代模式)]
**触发条件**:用户在开发过程中提出新功能、修改需求或迭代想法
**核心原则**:无缝衔接,不打断用户工作流。不需要开场白,直接接住用户的需求往下问。
[变更识别阶段]
目的:搞清楚用户要改什么
第一步:接住需求
**先上网搜索**:根据用户提出的变更内容上网搜索相关信息,确保追问基于最新知识
用户说"我觉得应该还要有一个AI一键推荐功能"
直接追问:"AI一键推荐什么推荐给谁这个按钮放哪个页面点了之后发生什么"
第二步:判断变更类型
根据 [迭代模式-追问深度判断] 确定这是重度、中度还是轻度变更
决定追问深度
[追问完善阶段]
目的:问到能直接改 Spec 为止
第一步:按深度追问
**先上网搜索**:每次追问前上网搜索相关信息,确保问题和建议基于最新知识
重度变更:问到能回答"这个变更会怎么影响现有产品"
中度变更:问到能回答"具体改成什么样"
轻度变更:确认理解正确即可
第二步:用户卡住时给方案
**先上网搜索**:给方案前上网搜索最新的解决方案和最佳实践
用户不知道怎么做 → 给 2-3 个选项 + 优劣
给完继续逼他选,选完继续逼下一个细节
第三步:冲突检测
加载现有 Product-Spec.md
检查新需求是否与现有内容冲突
发现冲突 → 直接指出冲突点 + 给解决方案 + 让用户选
**停止追问的标准**
- 能够直接动手改 Product Spec不需要再猜或假设
- 改完之后用户不会说"不是这个意思"
[文档更新阶段]
目的:更新 Product Spec 并记录变更
第一步:理解现有文档结构
加载现有 Spec 文件
识别其章节结构(可能和模板不同)
后续修改基于现有结构,不强行套用模板
第二步:直接修改源文件
在现有 Spec 上直接修改
保持文档整体结构不变
只改需要改的部分
第三步:更新 AI 能力需求
如果涉及新的 AI 功能:
- 在「AI 能力需求」章节添加新能力类型
- 说明新能力的用途
第四步:自动追加变更记录
在 Product-Spec-CHANGELOG.md 中追加本次变更
如果 CHANGELOG 文件不存在,创建一个
记录 Product Spec 迭代变更时,加载 templates/changelog-template.md 获取完整的变更记录格式和示例
根据对话内容自动生成变更描述
[迭代模式-追问深度判断]
**变更类型判断逻辑**(按顺序检查):
1. 涉及新 AI 能力?→ 重度
2. 涉及用户核心路径变更?→ 重度
3. 涉及布局结构(几栏、区域划分)?→ 重度
4. 新增主要功能模块?→ 重度
5. 涉及新功能但不改核心流程?→ 中度
6. 涉及现有功能的逻辑调整?→ 中度
7. 局部布局调整?→ 中度
8. 只是改文字、选项、样式?→ 轻度
**各类型追问标准**
| 变更类型 | 停止追问的条件 | 必须问清楚的内容 |
|---------|---------------|----------------|
| **重度** | 能回答"这个变更会怎么影响现有产品"时停止 | 为什么需要?影响哪些现有功能?用户流程怎么变?需要什么新的 AI 能力? |
| **中度** | 能回答"具体改成什么样"时停止 | 改哪里?改成什么?和现有的怎么配合? |
| **轻度** | 确认理解正确时停止 | 改什么?改成什么? |
[初始化]
执行 [启动检查]

View File

@@ -0,0 +1,111 @@
---
name: changelog-template
description: 变更记录模板。当 Product Spec 发生迭代变更时,按照此模板格式记录变更历史,输出为 Product-Spec-CHANGELOG.md 文件。
---
# 变更记录模板
本模板用于记录 Product Spec 的迭代变更历史。
---
## 文件命名
`Product-Spec-CHANGELOG.md`
---
## 模板格式
```markdown
# 变更记录
## [v1.2] - YYYY-MM-DD
### 新增
- <新增的功能或内容>
### 修改
- <修改的功能或内容>
### 删除
- <删除的功能或内容>
---
## [v1.1] - YYYY-MM-DD
### 新增
- <新增的功能或内容>
---
## [v1.0] - YYYY-MM-DD
- 初始版本
```
---
## 记录规则
- **版本号递增**:每次迭代 +0.1(如 v1.0 → v1.1 → v1.2
- **日期自动填充**:使用当天日期,格式 YYYY-MM-DD
- **变更描述**:根据对话内容自动生成,简明扼要
- **分类记录**:新增、修改、删除分开写,没有的分类不写
- **只记录实际改动**:没改的部分不记录
- **新增控件要写位置**:涉及 UI 变更时,说明控件放在哪里
---
## 完整示例
以下是「剧本分镜生成器」的变更记录示例,供参考:
```markdown
# 变更记录
## [v1.2] - 2025-12-08
### 新增
- 新增「AI 优化描述」按钮(角色设定区底部),点击后自动优化角色和场景的描述文字
- 新增分镜描述显示,每张分镜图下方展示 AI 生成的画面描述
### 修改
- 左侧输入区比例从 35% 改为 40%
- 「生成分镜」按钮样式改为更醒目的主色调
---
## [v1.1] - 2025-12-05
### 新增
- 新增「场景设定」功能区(角色设定区下方),用户可上传场景参考图建立视觉档案
- 新增「水墨」画风选项
- 新增图像理解能力,用于分析用户上传的参考图
### 修改
- 角色卡片布局优化,参考图预览尺寸从 80px 改为 120px
### 删除
- 移除「自动分页」功能(用户反馈更希望手动控制分页节奏)
---
## [v1.0] - 2025-12-01
- 初始版本
```
---
## 写作要点
1. **版本号**:从 v1.0 开始,每次迭代 +0.1,重大改版可以 +1.0
2. **日期格式**:统一用 YYYY-MM-DD方便排序和查找
3. **变更描述**
- 动词开头(新增、修改、删除、移除、调整)
- 说清楚改了什么、改成什么样
- 新增控件要写位置(如「角色设定区底部」)
- 数值变更要写前后对比(如「从 35% 改为 40%」)
- 如果有原因,简要说明(如「用户反馈不需要」)
4. **分类原则**
- 新增:之前没有的功能、控件、能力
- 修改:改变了现有内容的行为、样式、参数
- 删除:移除了之前有的功能
5. **颗粒度**:一条记录对应一个独立的变更点,不要把多个改动混在一起
6. **AI 能力变更**:如果新增或移除了 AI 能力,必须单独记录

View File

@@ -0,0 +1,197 @@
---
name: product-spec-template
description: Product Spec 输出模板。当需要生成产品需求文档时,按照此模板的结构和格式填充内容,输出为 Product-Spec.md 文件。
---
# Product Spec 输出模板
本模板用于生成结构完整的 Product Spec 文档。生成时按照此结构填充内容。
---
## 模板结构
**文件命名**Product-Spec.md
---
## 产品概述
<一段话说清楚>
- 这是什么产品
- 解决什么问题
- **目标用户是谁**(具体描述,不要只说「用户」)
- 核心价值是什么
## 应用场景
<列举 3-5 个具体场景在什么情况下怎么用解决什么问题>
## 功能需求
<核心功能辅助功能分类每条功能说明用户做什么 系统做什么 得到什么>
## UI 布局
<描述整体布局结构和各区域的详细设计需要包含>
- 整体是什么布局(几栏、比例、固定元素等)
- 每个区域放什么内容
- 控件的具体规范(位置、尺寸、样式等)
## 用户使用流程
<分步骤描述用户如何使用产品可以有多条路径如快速上手进阶使用>
## AI 能力需求
| 能力类型 | 用途说明 | 应用位置 |
|---------|---------|---------|
| <能力类型> | <做什么> | <在哪个环节触发> |
## 技术说明(可选)
<如果涉及以下内容需要说明>
- 数据存储:是否需要登录?数据存在哪里?
- 外部依赖:需要调用什么服务?有什么限制?
- 部署方式:纯前端?需要服务器?
## 补充说明
<如有需要用表格说明选项状态逻辑等>
---
## 完整示例
以下是一个「剧本分镜生成器」的 Product Spec 示例,供参考:
```markdown
## 产品概述
这是一个帮助漫画作者、短视频创作者、动画团队将剧本快速转化为分镜图的工具。
**目标用户**:有剧本但缺乏绘画能力、或者想快速出分镜草稿的创作者。他们可能是独立漫画作者、短视频博主、动画工作室的前期策划人员,共同的痛点是「脑子里有画面,但画不出来或画太慢」。
**核心价值**用户只需输入剧本文本、上传角色和场景参考图、选择画风AI 就会自动分析剧本结构,生成保持视觉一致性的分镜图,将原本需要数小时的分镜绘制工作缩短到几分钟。
## 应用场景
- **漫画创作**:独立漫画作者小王有一个 20 页的剧本需要先出分镜草稿再精修。他把剧本贴进来上传主角的参考图10 分钟就拿到了全部分镜草稿,可以直接在这个基础上精修。
- **短视频策划**:短视频博主小李要拍一个 3 分钟的剧情短片,需要给摄影师看分镜。她把脚本输入,选择「写实」风格,生成的分镜图直接可以当拍摄参考。
- **动画前期**:动画工作室要向客户提案,需要快速出一版分镜来展示剧本节奏。策划人员用这个工具 30 分钟出了 50 张分镜图,当天就能开提案会。
- **小说可视化**:网文作者想给自己的小说做宣传图,把关键场景描述输入,生成的分镜图可以直接用于社交媒体宣传。
- **教学演示**:小学语文老师想把一篇课文变成连环画给学生看,把课文内容输入,选择「动漫」风格,生成的图片可以直接做成 PPT。
## 功能需求
**核心功能**
- 剧本输入与分析:用户输入剧本文本 → 点击「生成分镜」→ AI 自动识别角色、场景和情节节拍,将剧本拆分为多页分镜
- 角色设定:用户添加角色卡片(名称 + 外观描述 + 参考图)→ 系统建立角色视觉档案,后续生成时保持外观一致
- 场景设定:用户添加场景卡片(名称 + 氛围描述 + 参考图)→ 系统建立场景视觉档案(可选,不设定则由 AI 根据剧本生成)
- 画风选择:用户从下拉框选择画风(漫画/动漫/写实/赛博朋克/水墨)→ 生成的分镜图采用对应视觉风格
- 分镜生成:用户点击「生成分镜」→ AI 生成当前页 9 张分镜图3x3 九宫格)→ 展示在右侧输出区
- 连续生成:用户点击「继续生成下一页」→ AI 基于前一页的画风和角色外观,生成下一页 9 张分镜图
**辅助功能**
- 批量下载:用户点击「下载全部」→ 系统将当前页 9 张图打包为 ZIP 下载
- 历史浏览:用户通过页面导航 → 切换查看已生成的历史页面
## UI 布局
### 整体布局
左右两栏布局,左侧输入区占 40%,右侧输出区占 60%。
### 左侧 - 输入区
- 顶部:项目名称输入框
- 剧本输入多行文本框placeholder「请输入剧本内容...」
- 角色设定区:
- 角色卡片列表,每张卡片包含:角色名、外观描述、参考图上传
- 「添加角色」按钮
- 场景设定区:
- 场景卡片列表,每张卡片包含:场景名、氛围描述、参考图上传
- 「添加场景」按钮
- 画风选择:下拉选择(漫画 / 动漫 / 写实 / 赛博朋克 / 水墨),默认「动漫」
- 底部:「生成分镜」主按钮,靠右对齐,醒目样式
### 右侧 - 输出区
- 分镜图展示区3x3 网格布局,展示 9 张独立分镜图
- 每张分镜图下方显示:分镜编号、简要描述
- 操作按钮:「下载全部」「继续生成下一页」
- 页面导航:显示当前页数,支持切换查看历史页面
## 用户使用流程
### 首次生成
1. 输入剧本内容
2. 添加角色:填写名称、外观描述,上传参考图
3. 添加场景:填写名称、氛围描述,上传参考图(可选)
4. 选择画风
5. 点击「生成分镜」
6. 在右侧查看生成的 9 张分镜图
7. 点击「下载全部」保存
### 连续生成
1. 完成首次生成后
2. 点击「继续生成下一页」
3. AI 基于前一页的画风和角色外观,生成下一页 9 张分镜图
4. 重复直到剧本完成
## AI 能力需求
| 能力类型 | 用途说明 | 应用位置 |
|---------|---------|---------|
| 文本理解与生成 | 分析剧本结构,识别角色、场景、情节节拍,规划分镜内容 | 点击「生成分镜」时 |
| 图像生成 | 根据分镜描述生成 3x3 九宫格分镜图 | 点击「生成分镜」「继续生成下一页」时 |
| 图像理解 | 分析用户上传的角色和场景参考图,提取视觉特征用于保持一致性 | 上传角色/场景参考图时 |
## 技术说明
- **数据存储**无需登录项目数据保存在浏览器本地存储LocalStorage关闭页面后仍可恢复
- **图像生成**:调用 AI 图像生成服务,每次生成 9 张图约需 30-60 秒
- **文件导出**:支持 PNG 格式批量下载,打包为 ZIP 文件
- **部署方式**:纯前端应用,无需服务器,可部署到任意静态托管平台
## 补充说明
| 选项 | 可选值 | 说明 |
|------|--------|------|
| 画风 | 漫画 / 动漫 / 写实 / 赛博朋克 / 水墨 | 决定分镜图的整体视觉风格 |
| 角色参考图 | 图片上传 | 用于建立角色视觉身份,确保一致性 |
| 场景参考图 | 图片上传(可选) | 用于建立场景氛围,不上传则由 AI 根据描述生成 |
```
---
## 写作要点
1. **产品概述**
- 一句话说清楚是什么
- **必须明确写出目标用户**:是谁、有什么特点、什么痛点
- 核心价值:用了这个产品能得到什么
2. **应用场景**
- 具体的人 + 具体的情况 + 具体的用法 + 解决什么问题
- 场景要有画面感,让人一看就懂
- 放在功能需求之前,帮助理解产品价值
3. **功能需求**
- 分「核心功能」和「辅助功能」
- 每条格式:用户做什么 → 系统做什么 → 得到什么
- 写清楚触发方式(点击什么按钮)
4. **UI 布局**
- 先写整体布局(几栏、比例)
- 再逐个区域描述内容
- 控件要具体:下拉框写出所有选项和默认值,按钮写明位置和样式
5. **用户流程**:分步骤,可以有多条路径
6. **AI 能力需求**
- 列出需要的 AI 能力类型
- 说明具体用途
- **写清楚在哪个环节触发**,方便开发理解调用时机
7. **技术说明**(可选):
- 数据存储方式
- 外部服务依赖
- 部署方式
- 只在有技术约束时写,没有就不写
8. **补充说明**:用表格,适合解释选项、状态、逻辑

View File

@@ -0,0 +1,345 @@
# Project Guidelines Skill (Example)
This is an example of a project-specific skill. Use this as a template for your own projects.
Based on a real production application: [Zenith](https://zenith.chat) - AI-powered customer discovery platform.
---
## When to Use
Reference this skill when working on the specific project it's designed for. Project skills contain:
- Architecture overview
- File structure
- Code patterns
- Testing requirements
- Deployment workflow
---
## Architecture Overview
**Tech Stack:**
- **Frontend**: Next.js 15 (App Router), TypeScript, React
- **Backend**: FastAPI (Python), Pydantic models
- **Database**: Supabase (PostgreSQL)
- **AI**: Claude API with tool calling and structured output
- **Deployment**: Google Cloud Run
- **Testing**: Playwright (E2E), pytest (backend), React Testing Library
**Services:**
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend │
│ Next.js 15 + TypeScript + TailwindCSS │
│ Deployed: Vercel / Cloud Run │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Backend │
│ FastAPI + Python 3.11 + Pydantic │
│ Deployed: Cloud Run │
└─────────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Supabase │ │ Claude │ │ Redis │
│ Database │ │ API │ │ Cache │
└──────────┘ └──────────┘ └──────────┘
```
---
## File Structure
```
project/
├── frontend/
│ └── src/
│ ├── app/ # Next.js app router pages
│ │ ├── api/ # API routes
│ │ ├── (auth)/ # Auth-protected routes
│ │ └── workspace/ # Main app workspace
│ ├── components/ # React components
│ │ ├── ui/ # Base UI components
│ │ ├── forms/ # Form components
│ │ └── layouts/ # Layout components
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utilities
│ ├── types/ # TypeScript definitions
│ └── config/ # Configuration
├── backend/
│ ├── routers/ # FastAPI route handlers
│ ├── models.py # Pydantic models
│ ├── main.py # FastAPI app entry
│ ├── auth_system.py # Authentication
│ ├── database.py # Database operations
│ ├── services/ # Business logic
│ └── tests/ # pytest tests
├── deploy/ # Deployment configs
├── docs/ # Documentation
└── scripts/ # Utility scripts
```
---
## Code Patterns
### API Response Format (FastAPI)
```python
from pydantic import BaseModel
from typing import Generic, TypeVar, Optional
T = TypeVar('T')
class ApiResponse(BaseModel, Generic[T]):
success: bool
data: Optional[T] = None
error: Optional[str] = None
@classmethod
def ok(cls, data: T) -> "ApiResponse[T]":
return cls(success=True, data=data)
@classmethod
def fail(cls, error: str) -> "ApiResponse[T]":
return cls(success=False, error=error)
```
### Frontend API Calls (TypeScript)
```typescript
interface ApiResponse<T> {
success: boolean
data?: T
error?: string
}
async function fetchApi<T>(
endpoint: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
try {
const response = await fetch(`/api${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (!response.ok) {
return { success: false, error: `HTTP ${response.status}` }
}
return await response.json()
} catch (error) {
return { success: false, error: String(error) }
}
}
```
### Claude AI Integration (Structured Output)
```python
from anthropic import Anthropic
from pydantic import BaseModel
class AnalysisResult(BaseModel):
summary: str
key_points: list[str]
confidence: float
async def analyze_with_claude(content: str) -> AnalysisResult:
client = Anthropic()
response = client.messages.create(
model="claude-sonnet-4-5-20250514",
max_tokens=1024,
messages=[{"role": "user", "content": content}],
tools=[{
"name": "provide_analysis",
"description": "Provide structured analysis",
"input_schema": AnalysisResult.model_json_schema()
}],
tool_choice={"type": "tool", "name": "provide_analysis"}
)
# Extract tool use result
tool_use = next(
block for block in response.content
if block.type == "tool_use"
)
return AnalysisResult(**tool_use.input)
```
### Custom Hooks (React)
```typescript
import { useState, useCallback } from 'react'
interface UseApiState<T> {
data: T | null
loading: boolean
error: string | null
}
export function useApi<T>(
fetchFn: () => Promise<ApiResponse<T>>
) {
const [state, setState] = useState<UseApiState<T>>({
data: null,
loading: false,
error: null,
})
const execute = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }))
const result = await fetchFn()
if (result.success) {
setState({ data: result.data!, loading: false, error: null })
} else {
setState({ data: null, loading: false, error: result.error! })
}
}, [fetchFn])
return { ...state, execute }
}
```
---
## Testing Requirements
### Backend (pytest)
```bash
# Run all tests
poetry run pytest tests/
# Run with coverage
poetry run pytest tests/ --cov=. --cov-report=html
# Run specific test file
poetry run pytest tests/test_auth.py -v
```
**Test structure:**
```python
import pytest
from httpx import AsyncClient
from main import app
@pytest.fixture
async def client():
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
@pytest.mark.asyncio
async def test_health_check(client: AsyncClient):
response = await client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
```
### Frontend (React Testing Library)
```bash
# Run tests
npm run test
# Run with coverage
npm run test -- --coverage
# Run E2E tests
npm run test:e2e
```
**Test structure:**
```typescript
import { render, screen, fireEvent } from '@testing-library/react'
import { WorkspacePanel } from './WorkspacePanel'
describe('WorkspacePanel', () => {
it('renders workspace correctly', () => {
render(<WorkspacePanel />)
expect(screen.getByRole('main')).toBeInTheDocument()
})
it('handles session creation', async () => {
render(<WorkspacePanel />)
fireEvent.click(screen.getByText('New Session'))
expect(await screen.findByText('Session created')).toBeInTheDocument()
})
})
```
---
## Deployment Workflow
### Pre-Deployment Checklist
- [ ] All tests passing locally
- [ ] `npm run build` succeeds (frontend)
- [ ] `poetry run pytest` passes (backend)
- [ ] No hardcoded secrets
- [ ] Environment variables documented
- [ ] Database migrations ready
### Deployment Commands
```bash
# Build and deploy frontend
cd frontend && npm run build
gcloud run deploy frontend --source .
# Build and deploy backend
cd backend
gcloud run deploy backend --source .
```
### Environment Variables
```bash
# Frontend (.env.local)
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
# Backend (.env)
DATABASE_URL=postgresql://...
ANTHROPIC_API_KEY=sk-ant-...
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_KEY=eyJ...
```
---
## Critical Rules
1. **No emojis** in code, comments, or documentation
2. **Immutability** - never mutate objects or arrays
3. **TDD** - write tests before implementation
4. **80% coverage** minimum
5. **Many small files** - 200-400 lines typical, 800 max
6. **No console.log** in production code
7. **Proper error handling** with try/catch
8. **Input validation** with Pydantic/Zod
---
## Related Skills
- `coding-standards.md` - General coding best practices
- `backend-patterns.md` - API and database patterns
- `frontend-patterns.md` - React and Next.js patterns
- `tdd-workflow/` - Test-driven development methodology

View File

@@ -0,0 +1,568 @@
---
name: security-review
description: Use this skill when adding authentication, handling user input, working with secrets, creating API endpoints, or implementing payment/sensitive features. Provides comprehensive security checklist and patterns.
---
# Security Review Skill
Security best practices for Python/FastAPI applications handling sensitive invoice data.
## When to Activate
- Implementing authentication or authorization
- Handling user input or file uploads
- Creating new API endpoints
- Working with secrets or credentials
- Processing sensitive invoice data
- Integrating third-party APIs
- Database operations with user data
## Security Checklist
### 1. Secrets Management
#### NEVER Do This
```python
# Hardcoded secrets - CRITICAL VULNERABILITY
api_key = "sk-proj-xxxxx"
db_password = "password123"
```
#### ALWAYS Do This
```python
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
db_password: str
api_key: str
model_path: str = "runs/train/invoice_fields/weights/best.pt"
class Config:
env_file = ".env"
settings = Settings()
# Verify secrets exist
if not settings.db_password:
raise RuntimeError("DB_PASSWORD not configured")
```
#### Verification Steps
- [ ] No hardcoded API keys, tokens, or passwords
- [ ] All secrets in environment variables
- [ ] `.env` in .gitignore
- [ ] No secrets in git history
- [ ] `.env.example` with placeholder values
### 2. Input Validation
#### Always Validate User Input
```python
from pydantic import BaseModel, Field, field_validator
from fastapi import HTTPException
import re
class InvoiceRequest(BaseModel):
invoice_number: str = Field(..., min_length=1, max_length=50)
amount: float = Field(..., gt=0, le=1_000_000)
bankgiro: str | None = None
@field_validator("invoice_number")
@classmethod
def validate_invoice_number(cls, v: str) -> str:
# Whitelist validation - only allow safe characters
if not re.match(r"^[A-Za-z0-9\-_]+$", v):
raise ValueError("Invalid invoice number format")
return v
@field_validator("bankgiro")
@classmethod
def validate_bankgiro(cls, v: str | None) -> str | None:
if v is None:
return None
cleaned = re.sub(r"[^0-9]", "", v)
if not (7 <= len(cleaned) <= 8):
raise ValueError("Bankgiro must be 7-8 digits")
return cleaned
```
#### File Upload Validation
```python
from fastapi import UploadFile, HTTPException
from pathlib import Path
ALLOWED_EXTENSIONS = {".pdf"}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
async def validate_pdf_upload(file: UploadFile) -> bytes:
"""Validate PDF upload with security checks."""
# Extension check
ext = Path(file.filename or "").suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(400, f"Only PDF files allowed, got {ext}")
# Read content
content = await file.read()
# Size check
if len(content) > MAX_FILE_SIZE:
raise HTTPException(400, f"File too large (max {MAX_FILE_SIZE // 1024 // 1024}MB)")
# Magic bytes check (PDF signature)
if not content.startswith(b"%PDF"):
raise HTTPException(400, "Invalid PDF file format")
return content
```
#### Verification Steps
- [ ] All user inputs validated with Pydantic
- [ ] File uploads restricted (size, type, extension, magic bytes)
- [ ] No direct use of user input in queries
- [ ] Whitelist validation (not blacklist)
- [ ] Error messages don't leak sensitive info
### 3. SQL Injection Prevention
#### NEVER Concatenate SQL
```python
# DANGEROUS - SQL Injection vulnerability
query = f"SELECT * FROM documents WHERE id = '{user_input}'"
cur.execute(query)
```
#### ALWAYS Use Parameterized Queries
```python
import psycopg2
# Safe - parameterized query with %s placeholders
cur.execute(
"SELECT * FROM documents WHERE id = %s AND status = %s",
(document_id, status)
)
# Safe - named parameters
cur.execute(
"SELECT * FROM documents WHERE id = %(id)s",
{"id": document_id}
)
# Safe - psycopg2.sql for dynamic identifiers
from psycopg2 import sql
cur.execute(
sql.SQL("SELECT {} FROM {} WHERE id = %s").format(
sql.Identifier("invoice_number"),
sql.Identifier("documents")
),
(document_id,)
)
```
#### Verification Steps
- [ ] All database queries use parameterized queries (%s or %(name)s)
- [ ] No string concatenation or f-strings in SQL
- [ ] psycopg2.sql module used for dynamic identifiers
- [ ] No user input in table/column names
### 4. Path Traversal Prevention
#### NEVER Trust User Paths
```python
# DANGEROUS - Path traversal vulnerability
filename = request.query_params.get("file")
with open(f"/data/{filename}", "r") as f: # Attacker: ../../../etc/passwd
return f.read()
```
#### ALWAYS Validate Paths
```python
from pathlib import Path
ALLOWED_DIR = Path("/data/uploads").resolve()
def get_safe_path(filename: str) -> Path:
"""Get safe file path, preventing path traversal."""
# Remove any path components
safe_name = Path(filename).name
# Validate filename characters
if not re.match(r"^[A-Za-z0-9_\-\.]+$", safe_name):
raise HTTPException(400, "Invalid filename")
# Resolve and verify within allowed directory
full_path = (ALLOWED_DIR / safe_name).resolve()
if not full_path.is_relative_to(ALLOWED_DIR):
raise HTTPException(400, "Invalid file path")
return full_path
```
#### Verification Steps
- [ ] User-provided filenames sanitized
- [ ] Paths resolved and validated against allowed directory
- [ ] No direct concatenation of user input into paths
- [ ] Whitelist characters in filenames
### 5. Authentication & Authorization
#### API Key Validation
```python
from fastapi import Depends, HTTPException, Security
from fastapi.security import APIKeyHeader
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def verify_api_key(api_key: str = Security(api_key_header)) -> str:
if not api_key:
raise HTTPException(401, "API key required")
# Constant-time comparison to prevent timing attacks
import hmac
if not hmac.compare_digest(api_key, settings.api_key):
raise HTTPException(403, "Invalid API key")
return api_key
@router.post("/infer")
async def infer(
file: UploadFile,
api_key: str = Depends(verify_api_key)
):
...
```
#### Role-Based Access Control
```python
from enum import Enum
class UserRole(str, Enum):
USER = "user"
ADMIN = "admin"
def require_role(required_role: UserRole):
async def role_checker(current_user: User = Depends(get_current_user)):
if current_user.role != required_role:
raise HTTPException(403, "Insufficient permissions")
return current_user
return role_checker
@router.delete("/documents/{doc_id}")
async def delete_document(
doc_id: str,
user: User = Depends(require_role(UserRole.ADMIN))
):
...
```
#### Verification Steps
- [ ] API keys validated with constant-time comparison
- [ ] Authorization checks before sensitive operations
- [ ] Role-based access control implemented
- [ ] Session/token validation on protected routes
### 6. Rate Limiting
#### Rate Limiter Implementation
```python
from time import time
from collections import defaultdict
from fastapi import Request, HTTPException
class RateLimiter:
def __init__(self):
self.requests: dict[str, list[float]] = defaultdict(list)
def check_limit(
self,
identifier: str,
max_requests: int,
window_seconds: int
) -> bool:
now = time()
# Clean old requests
self.requests[identifier] = [
t for t in self.requests[identifier]
if now - t < window_seconds
]
# Check limit
if len(self.requests[identifier]) >= max_requests:
return False
self.requests[identifier].append(now)
return True
limiter = RateLimiter()
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
client_ip = request.client.host if request.client else "unknown"
# 100 requests per minute for general endpoints
if not limiter.check_limit(client_ip, max_requests=100, window_seconds=60):
raise HTTPException(429, "Rate limit exceeded. Try again later.")
return await call_next(request)
```
#### Stricter Limits for Expensive Operations
```python
# Inference endpoint: 10 requests per minute
async def check_inference_rate_limit(request: Request):
client_ip = request.client.host if request.client else "unknown"
if not limiter.check_limit(f"infer:{client_ip}", max_requests=10, window_seconds=60):
raise HTTPException(429, "Inference rate limit exceeded")
@router.post("/infer")
async def infer(
file: UploadFile,
_: None = Depends(check_inference_rate_limit)
):
...
```
#### Verification Steps
- [ ] Rate limiting on all API endpoints
- [ ] Stricter limits on expensive operations (inference, OCR)
- [ ] IP-based rate limiting
- [ ] Clear error messages for rate-limited requests
### 7. Sensitive Data Exposure
#### Logging
```python
import logging
logger = logging.getLogger(__name__)
# WRONG: Logging sensitive data
logger.info(f"Processing invoice: {invoice_data}") # May contain sensitive info
logger.error(f"DB error with password: {db_password}")
# CORRECT: Redact sensitive data
logger.info(f"Processing invoice: id={doc_id}")
logger.error(f"DB connection failed to {db_host}:{db_port}")
# CORRECT: Structured logging with safe fields only
logger.info(
"Invoice processed",
extra={
"document_id": doc_id,
"field_count": len(fields),
"processing_time_ms": elapsed_ms
}
)
```
#### Error Messages
```python
# WRONG: Exposing internal details
@app.exception_handler(Exception)
async def error_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={
"error": str(exc),
"traceback": traceback.format_exc() # NEVER expose!
}
)
# CORRECT: Generic error messages
@app.exception_handler(Exception)
async def error_handler(request: Request, exc: Exception):
logger.error(f"Unhandled error: {exc}", exc_info=True) # Log internally
return JSONResponse(
status_code=500,
content={"success": False, "error": "An error occurred"}
)
```
#### Verification Steps
- [ ] No passwords, tokens, or secrets in logs
- [ ] Error messages generic for users
- [ ] Detailed errors only in server logs
- [ ] No stack traces exposed to users
- [ ] Invoice data (amounts, account numbers) not logged
### 8. CORS Configuration
```python
from fastapi.middleware.cors import CORSMiddleware
# WRONG: Allow all origins
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # DANGEROUS in production
allow_credentials=True,
)
# CORRECT: Specific origins
ALLOWED_ORIGINS = [
"http://localhost:8000",
"https://your-domain.com",
]
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
```
#### Verification Steps
- [ ] CORS origins explicitly listed
- [ ] No wildcard origins in production
- [ ] Credentials only with specific origins
### 9. Temporary File Security
```python
import tempfile
from pathlib import Path
from contextlib import contextmanager
@contextmanager
def secure_temp_file(suffix: str = ".pdf"):
"""Create secure temporary file that is always cleaned up."""
tmp_path = None
try:
with tempfile.NamedTemporaryFile(
suffix=suffix,
delete=False,
dir="/tmp/invoice-master" # Dedicated temp directory
) as tmp:
tmp_path = Path(tmp.name)
yield tmp_path
finally:
if tmp_path and tmp_path.exists():
tmp_path.unlink()
# Usage
async def process_upload(file: UploadFile):
with secure_temp_file(".pdf") as tmp_path:
content = await validate_pdf_upload(file)
tmp_path.write_bytes(content)
result = pipeline.process(tmp_path)
# File automatically cleaned up
return result
```
#### Verification Steps
- [ ] Temporary files always cleaned up (use context managers)
- [ ] Temp directory has restricted permissions
- [ ] No leftover files after processing errors
### 10. Dependency Security
#### Regular Updates
```bash
# Check for vulnerabilities
pip-audit
# Update dependencies
pip install --upgrade -r requirements.txt
# Check for outdated packages
pip list --outdated
```
#### Lock Files
```bash
# Create requirements lock file
pip freeze > requirements.lock
# Install from lock file for reproducible builds
pip install -r requirements.lock
```
#### Verification Steps
- [ ] Dependencies up to date
- [ ] No known vulnerabilities (pip-audit clean)
- [ ] requirements.txt pinned versions
- [ ] Regular security updates scheduled
## Security Testing
### Automated Security Tests
```python
import pytest
from fastapi.testclient import TestClient
def test_requires_api_key(client: TestClient):
"""Test authentication required."""
response = client.post("/api/v1/infer")
assert response.status_code == 401
def test_invalid_api_key_rejected(client: TestClient):
"""Test invalid API key rejected."""
response = client.post(
"/api/v1/infer",
headers={"X-API-Key": "invalid-key"}
)
assert response.status_code == 403
def test_sql_injection_prevented(client: TestClient):
"""Test SQL injection attempt rejected."""
response = client.get(
"/api/v1/documents",
params={"id": "'; DROP TABLE documents; --"}
)
# Should return validation error, not execute SQL
assert response.status_code in (400, 422)
def test_path_traversal_prevented(client: TestClient):
"""Test path traversal attempt rejected."""
response = client.get("/api/v1/results/../../etc/passwd")
assert response.status_code == 400
def test_rate_limit_enforced(client: TestClient):
"""Test rate limiting works."""
responses = [
client.post("/api/v1/infer", files={"file": b"test"})
for _ in range(15)
]
rate_limited = [r for r in responses if r.status_code == 429]
assert len(rate_limited) > 0
def test_large_file_rejected(client: TestClient):
"""Test file size limit enforced."""
large_content = b"x" * (11 * 1024 * 1024) # 11MB
response = client.post(
"/api/v1/infer",
files={"file": ("test.pdf", large_content)}
)
assert response.status_code == 400
```
## Pre-Deployment Security Checklist
Before ANY production deployment:
- [ ] **Secrets**: No hardcoded secrets, all in env vars
- [ ] **Input Validation**: All user inputs validated with Pydantic
- [ ] **SQL Injection**: All queries use parameterized queries
- [ ] **Path Traversal**: File paths validated and sanitized
- [ ] **Authentication**: API key or token validation
- [ ] **Authorization**: Role checks in place
- [ ] **Rate Limiting**: Enabled on all endpoints
- [ ] **HTTPS**: Enforced in production
- [ ] **CORS**: Properly configured (no wildcards)
- [ ] **Error Handling**: No sensitive data in errors
- [ ] **Logging**: No sensitive data logged
- [ ] **File Uploads**: Validated (size, type, magic bytes)
- [ ] **Temp Files**: Always cleaned up
- [ ] **Dependencies**: Up to date, no vulnerabilities
## Resources
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [FastAPI Security](https://fastapi.tiangolo.com/tutorial/security/)
- [Bandit (Python Security Linter)](https://bandit.readthedocs.io/)
- [pip-audit](https://pypi.org/project/pip-audit/)
---
**Remember**: Security is not optional. One vulnerability can compromise sensitive invoice data. When in doubt, err on the side of caution.

View File

@@ -0,0 +1,63 @@
---
name: strategic-compact
description: Suggests manual context compaction at logical intervals to preserve context through task phases rather than arbitrary auto-compaction.
---
# Strategic Compact Skill
Suggests manual `/compact` at strategic points in your workflow rather than relying on arbitrary auto-compaction.
## Why Strategic Compaction?
Auto-compaction triggers at arbitrary points:
- Often mid-task, losing important context
- No awareness of logical task boundaries
- Can interrupt complex multi-step operations
Strategic compaction at logical boundaries:
- **After exploration, before execution** - Compact research context, keep implementation plan
- **After completing a milestone** - Fresh start for next phase
- **Before major context shifts** - Clear exploration context before different task
## How It Works
The `suggest-compact.sh` script runs on PreToolUse (Edit/Write) and:
1. **Tracks tool calls** - Counts tool invocations in session
2. **Threshold detection** - Suggests at configurable threshold (default: 50 calls)
3. **Periodic reminders** - Reminds every 25 calls after threshold
## Hook Setup
Add to your `~/.claude/settings.json`:
```json
{
"hooks": {
"PreToolUse": [{
"matcher": "tool == \"Edit\" || tool == \"Write\"",
"hooks": [{
"type": "command",
"command": "~/.claude/skills/strategic-compact/suggest-compact.sh"
}]
}]
}
}
```
## Configuration
Environment variables:
- `COMPACT_THRESHOLD` - Tool calls before first suggestion (default: 50)
## Best Practices
1. **Compact after planning** - Once plan is finalized, compact to start fresh
2. **Compact after debugging** - Clear error-resolution context before continuing
3. **Don't compact mid-implementation** - Preserve context for related changes
4. **Read the suggestion** - The hook tells you *when*, you decide *if*
## Related
- [The Longform Guide](https://x.com/affaanmustafa/status/2014040193557471352) - Token optimization section
- Memory persistence hooks - For state that survives compaction

View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Strategic Compact Suggester
# Runs on PreToolUse or periodically to suggest manual compaction at logical intervals
#
# Why manual over auto-compact:
# - Auto-compact happens at arbitrary points, often mid-task
# - Strategic compacting preserves context through logical phases
# - Compact after exploration, before execution
# - Compact after completing a milestone, before starting next
#
# Hook config (in ~/.claude/settings.json):
# {
# "hooks": {
# "PreToolUse": [{
# "matcher": "Edit|Write",
# "hooks": [{
# "type": "command",
# "command": "~/.claude/skills/strategic-compact/suggest-compact.sh"
# }]
# }]
# }
# }
#
# Criteria for suggesting compact:
# - Session has been running for extended period
# - Large number of tool calls made
# - Transitioning from research/exploration to implementation
# - Plan has been finalized
# Track tool call count (increment in a temp file)
COUNTER_FILE="/tmp/claude-tool-count-$$"
THRESHOLD=${COMPACT_THRESHOLD:-50}
# Initialize or increment counter
if [ -f "$COUNTER_FILE" ]; then
count=$(cat "$COUNTER_FILE")
count=$((count + 1))
echo "$count" > "$COUNTER_FILE"
else
echo "1" > "$COUNTER_FILE"
count=1
fi
# Suggest compact after threshold tool calls
if [ "$count" -eq "$THRESHOLD" ]; then
echo "[StrategicCompact] $THRESHOLD tool calls reached - consider /compact if transitioning phases" >&2
fi
# Suggest at regular intervals after threshold
if [ "$count" -gt "$THRESHOLD" ] && [ $((count % 25)) -eq 0 ]; then
echo "[StrategicCompact] $count tool calls - good checkpoint for /compact if context is stale" >&2
fi

View File

@@ -0,0 +1,553 @@
---
name: tdd-workflow
description: Use this skill when writing new features, fixing bugs, or refactoring code. Enforces test-driven development with 80%+ coverage including unit, integration, and E2E tests.
---
# Test-Driven Development Workflow
TDD principles for Python/FastAPI development with pytest.
## When to Activate
- Writing new features or functionality
- Fixing bugs or issues
- Refactoring existing code
- Adding API endpoints
- Creating new field extractors or normalizers
## Core Principles
### 1. Tests BEFORE Code
ALWAYS write tests first, then implement code to make tests pass.
### 2. Coverage Requirements
- Minimum 80% coverage (unit + integration + E2E)
- All edge cases covered
- Error scenarios tested
- Boundary conditions verified
### 3. Test Types
#### Unit Tests
- Individual functions and utilities
- Normalizers and validators
- Parsers and extractors
- Pure functions
#### Integration Tests
- API endpoints
- Database operations
- OCR + YOLO pipeline
- Service interactions
#### E2E Tests
- Complete inference pipeline
- PDF → Fields workflow
- API health and inference endpoints
## TDD Workflow Steps
### Step 1: Write User Journeys
```
As a [role], I want to [action], so that [benefit]
Example:
As an invoice processor, I want to extract Bankgiro from payment_line,
so that I can cross-validate OCR results.
```
### Step 2: Generate Test Cases
For each user journey, create comprehensive test cases:
```python
import pytest
class TestPaymentLineParser:
"""Tests for payment_line parsing and field extraction."""
def test_parse_payment_line_extracts_bankgiro(self):
"""Should extract Bankgiro from valid payment line."""
# Test implementation
pass
def test_parse_payment_line_handles_missing_checksum(self):
"""Should handle payment lines without checksum."""
pass
def test_parse_payment_line_validates_checksum(self):
"""Should validate checksum when present."""
pass
def test_parse_payment_line_returns_none_for_invalid(self):
"""Should return None for invalid payment lines."""
pass
```
### Step 3: Run Tests (They Should Fail)
```bash
pytest tests/test_ocr/test_machine_code_parser.py -v
# Tests should fail - we haven't implemented yet
```
### Step 4: Implement Code
Write minimal code to make tests pass:
```python
def parse_payment_line(line: str) -> PaymentLineData | None:
"""Parse Swedish payment line and extract fields."""
# Implementation guided by tests
pass
```
### Step 5: Run Tests Again
```bash
pytest tests/test_ocr/test_machine_code_parser.py -v
# Tests should now pass
```
### Step 6: Refactor
Improve code quality while keeping tests green:
- Remove duplication
- Improve naming
- Optimize performance
- Enhance readability
### Step 7: Verify Coverage
```bash
pytest --cov=src --cov-report=term-missing
# Verify 80%+ coverage achieved
```
## Testing Patterns
### Unit Test Pattern (pytest)
```python
import pytest
from src.normalize.bankgiro_normalizer import normalize_bankgiro
class TestBankgiroNormalizer:
"""Tests for Bankgiro normalization."""
def test_normalize_removes_hyphens(self):
"""Should remove hyphens from Bankgiro."""
result = normalize_bankgiro("123-4567")
assert result == "1234567"
def test_normalize_removes_spaces(self):
"""Should remove spaces from Bankgiro."""
result = normalize_bankgiro("123 4567")
assert result == "1234567"
def test_normalize_validates_length(self):
"""Should validate Bankgiro is 7-8 digits."""
result = normalize_bankgiro("123456") # 6 digits
assert result is None
def test_normalize_validates_checksum(self):
"""Should validate Luhn checksum."""
result = normalize_bankgiro("1234568") # Invalid checksum
assert result is None
@pytest.mark.parametrize("input_value,expected", [
("123-4567", "1234567"),
("1234567", "1234567"),
("123 4567", "1234567"),
("BG 123-4567", "1234567"),
])
def test_normalize_various_formats(self, input_value, expected):
"""Should handle various input formats."""
result = normalize_bankgiro(input_value)
assert result == expected
```
### API Integration Test Pattern
```python
import pytest
from fastapi.testclient import TestClient
from src.web.app import app
@pytest.fixture
def client():
return TestClient(app)
class TestHealthEndpoint:
"""Tests for /api/v1/health endpoint."""
def test_health_returns_200(self, client):
"""Should return 200 OK."""
response = client.get("/api/v1/health")
assert response.status_code == 200
def test_health_returns_status(self, client):
"""Should return health status."""
response = client.get("/api/v1/health")
data = response.json()
assert data["status"] == "healthy"
assert "model_loaded" in data
class TestInferEndpoint:
"""Tests for /api/v1/infer endpoint."""
def test_infer_requires_file(self, client):
"""Should require file upload."""
response = client.post("/api/v1/infer")
assert response.status_code == 422
def test_infer_rejects_non_pdf(self, client):
"""Should reject non-PDF files."""
response = client.post(
"/api/v1/infer",
files={"file": ("test.txt", b"not a pdf", "text/plain")}
)
assert response.status_code == 400
def test_infer_returns_fields(self, client, sample_invoice_pdf):
"""Should return extracted fields."""
with open(sample_invoice_pdf, "rb") as f:
response = client.post(
"/api/v1/infer",
files={"file": ("invoice.pdf", f, "application/pdf")}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "fields" in data
```
### E2E Test Pattern
```python
import pytest
import httpx
from pathlib import Path
@pytest.fixture(scope="module")
def running_server():
"""Ensure server is running for E2E tests."""
# Server should be started before running E2E tests
base_url = "http://localhost:8000"
yield base_url
class TestInferencePipeline:
"""E2E tests for complete inference pipeline."""
def test_health_check(self, running_server):
"""Should pass health check."""
response = httpx.get(f"{running_server}/api/v1/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert data["model_loaded"] is True
def test_pdf_inference_returns_fields(self, running_server):
"""Should extract fields from PDF."""
pdf_path = Path("tests/fixtures/sample_invoice.pdf")
with open(pdf_path, "rb") as f:
response = httpx.post(
f"{running_server}/api/v1/infer",
files={"file": ("invoice.pdf", f, "application/pdf")}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "fields" in data
assert len(data["fields"]) > 0
def test_cross_validation_included(self, running_server):
"""Should include cross-validation for invoices with payment_line."""
pdf_path = Path("tests/fixtures/invoice_with_payment_line.pdf")
with open(pdf_path, "rb") as f:
response = httpx.post(
f"{running_server}/api/v1/infer",
files={"file": ("invoice.pdf", f, "application/pdf")}
)
data = response.json()
if data["fields"].get("payment_line"):
assert "cross_validation" in data
```
## Test File Organization
```
tests/
├── conftest.py # Shared fixtures
├── fixtures/ # Test data files
│ ├── sample_invoice.pdf
│ └── invoice_with_payment_line.pdf
├── test_cli/
│ └── test_infer.py
├── test_pdf/
│ ├── test_extractor.py
│ └── test_renderer.py
├── test_ocr/
│ ├── test_paddle_ocr.py
│ └── test_machine_code_parser.py
├── test_inference/
│ ├── test_pipeline.py
│ ├── test_yolo_detector.py
│ └── test_field_extractor.py
├── test_normalize/
│ ├── test_bankgiro_normalizer.py
│ ├── test_date_normalizer.py
│ └── test_amount_normalizer.py
├── test_web/
│ ├── test_routes.py
│ └── test_services.py
└── e2e/
└── test_inference_e2e.py
```
## Mocking External Services
### Mock PaddleOCR
```python
import pytest
from unittest.mock import Mock, patch
@pytest.fixture
def mock_paddle_ocr():
"""Mock PaddleOCR for unit tests."""
with patch("src.ocr.paddle_ocr.PaddleOCR") as mock:
instance = Mock()
instance.ocr.return_value = [
[
[[[0, 0], [100, 0], [100, 20], [0, 20]], ("Invoice Number", 0.95)],
[[[0, 30], [100, 30], [100, 50], [0, 50]], ("INV-2024-001", 0.98)]
]
]
mock.return_value = instance
yield instance
```
### Mock YOLO Model
```python
@pytest.fixture
def mock_yolo_model():
"""Mock YOLO model for unit tests."""
with patch("src.inference.yolo_detector.YOLO") as mock:
instance = Mock()
# Mock detection results
instance.return_value = Mock(
boxes=Mock(
xyxy=[[10, 20, 100, 50]],
conf=[0.95],
cls=[0] # invoice_number class
)
)
mock.return_value = instance
yield instance
```
### Mock Database
```python
@pytest.fixture
def mock_db_connection():
"""Mock database connection for unit tests."""
with patch("src.data.db.get_db_connection") as mock:
conn = Mock()
cursor = Mock()
cursor.fetchall.return_value = [
("doc-123", "processed", {"invoice_number": "INV-001"})
]
cursor.fetchone.return_value = ("doc-123",)
conn.cursor.return_value.__enter__ = Mock(return_value=cursor)
conn.cursor.return_value.__exit__ = Mock(return_value=False)
mock.return_value.__enter__ = Mock(return_value=conn)
mock.return_value.__exit__ = Mock(return_value=False)
yield conn
```
## Test Coverage Verification
### Run Coverage Report
```bash
# Run with coverage
pytest --cov=src --cov-report=term-missing
# Generate HTML report
pytest --cov=src --cov-report=html
# Open htmlcov/index.html in browser
```
### Coverage Configuration (pyproject.toml)
```toml
[tool.coverage.run]
source = ["src"]
omit = ["*/__init__.py", "*/test_*.py"]
[tool.coverage.report]
fail_under = 80
show_missing = true
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
```
## Common Testing Mistakes to Avoid
### WRONG: Testing Implementation Details
```python
# Don't test internal state
def test_parser_internal_state():
parser = PaymentLineParser()
parser._parse("...")
assert parser._groups == [...] # Internal state
```
### CORRECT: Test Public Interface
```python
# Test what users see
def test_parser_extracts_bankgiro():
result = parse_payment_line("...")
assert result.bankgiro == "1234567"
```
### WRONG: No Test Isolation
```python
# Tests depend on each other
class TestDocuments:
def test_creates_document(self):
create_document(...) # Creates in DB
def test_updates_document(self):
update_document(...) # Depends on previous test
```
### CORRECT: Independent Tests
```python
# Each test sets up its own data
class TestDocuments:
def test_creates_document(self, mock_db):
result = create_document(...)
assert result.id is not None
def test_updates_document(self, mock_db):
# Create own test data
doc = create_document(...)
result = update_document(doc.id, ...)
assert result.status == "updated"
```
### WRONG: Testing Too Much
```python
# One test doing everything
def test_full_invoice_processing():
# Load PDF
# Extract images
# Run YOLO
# Run OCR
# Normalize fields
# Save to DB
# Return response
```
### CORRECT: Focused Tests
```python
def test_yolo_detects_invoice_number():
"""Test only YOLO detection."""
result = detector.detect(image)
assert any(d.label == "invoice_number" for d in result)
def test_ocr_extracts_text():
"""Test only OCR extraction."""
result = ocr.extract(image, bbox)
assert result == "INV-2024-001"
def test_normalizer_formats_date():
"""Test only date normalization."""
result = normalize_date("2024-01-15")
assert result == "2024-01-15"
```
## Fixtures (conftest.py)
```python
import pytest
from pathlib import Path
from fastapi.testclient import TestClient
@pytest.fixture
def sample_invoice_pdf(tmp_path: Path) -> Path:
"""Create sample invoice PDF for testing."""
pdf_path = tmp_path / "invoice.pdf"
# Copy from fixtures or create minimal PDF
src = Path("tests/fixtures/sample_invoice.pdf")
if src.exists():
pdf_path.write_bytes(src.read_bytes())
return pdf_path
@pytest.fixture
def client():
"""FastAPI test client."""
from src.web.app import app
return TestClient(app)
@pytest.fixture
def sample_payment_line() -> str:
"""Sample Swedish payment line for testing."""
return "1234567#0000000012345#230115#00012345678901234567#1"
```
## Continuous Testing
### Watch Mode During Development
```bash
# Using pytest-watch
ptw -- tests/test_ocr/
# Tests run automatically on file changes
```
### Pre-Commit Hook
```bash
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: pytest
name: pytest
entry: pytest --tb=short -q
language: system
pass_filenames: false
always_run: true
```
### CI/CD Integration (GitHub Actions)
```yaml
- name: Run Tests
run: |
pytest --cov=src --cov-report=xml
- name: Upload Coverage
uses: codecov/codecov-action@v3
with:
file: coverage.xml
```
## Best Practices
1. **Write Tests First** - Always TDD
2. **One Assert Per Test** - Focus on single behavior
3. **Descriptive Test Names** - `test_<what>_<condition>_<expected>`
4. **Arrange-Act-Assert** - Clear test structure
5. **Mock External Dependencies** - Isolate unit tests
6. **Test Edge Cases** - None, empty, invalid, boundary
7. **Test Error Paths** - Not just happy paths
8. **Keep Tests Fast** - Unit tests < 50ms each
9. **Clean Up After Tests** - Use fixtures with cleanup
10. **Review Coverage Reports** - Identify gaps
## Success Metrics
- 80%+ code coverage achieved
- All tests passing (green)
- No skipped or disabled tests
- Fast test execution (< 60s for unit tests)
- E2E tests cover critical inference flow
- Tests catch bugs before production
---
**Remember**: Tests are not optional. They are the safety net that enables confident refactoring, rapid development, and production reliability.

View File

@@ -0,0 +1,139 @@
---
name: ui-prompt-generator
description: 读取 Product-Spec.md 中的功能需求和 UI 布局,生成可用于 AI 绘图工具的原型图提示词。与 product-spec-builder 配套使用,帮助用户快速将需求文档转化为视觉原型。
---
[角色]
你是一位 UI/UX 设计专家,擅长将产品需求转化为精准的视觉描述。
你能够从结构化的产品文档中提取关键信息,并转化为 AI 绘图工具可以理解的提示词,帮助用户快速生成产品原型图。
[任务]
读取 Product-Spec.md提取功能需求和 UI 布局信息,补充必要的视觉参数,生成可直接用于文生图工具的原型图提示词。
最终输出按页面拆分的提示词,用户可以直接复制到 AI 绘图工具生成原型图。
[技能]
- **文档解析**:从 Product-Spec.md 提取产品概述、功能需求、UI 布局、用户流程
- **页面识别**:根据产品复杂度识别需要生成几个页面
- **视觉转换**:将结构化的布局描述转化为视觉语言
- **提示词生成**:输出高质量的英文文生图提示词
[文件结构]
```
ui-prompt-generator/
├── SKILL.md # 主 Skill 定义(本文件)
└── templates/
└── ui-prompt-template.md # 提示词输出模板
```
[总体规则]
- 始终使用中文与用户交流
- 提示词使用英文输出AI 绘图工具英文效果更好)
- 必须先读取 Product-Spec.md不存在则提示用户先完成需求收集
- 不重复追问 Product-Spec.md 里已有的信息
- 用户不确定的信息,直接使用默认值继续推进
- 按页面拆分生成提示词,每个页面一条提示词
- 保持专业友好的语气
[视觉风格选项]
| 风格 | 英文 | 说明 | 适用场景 |
|------|------|------|---------|
| 现代极简 | Minimalism | 简洁留白、干净利落 | 工具类、企业应用 |
| 玻璃拟态 | Glassmorphism | 毛玻璃效果、半透明层叠 | 科技产品、仪表盘 |
| 新拟态 | Neomorphism | 柔和阴影、微凸起效果 | 音乐播放器、控制面板 |
| 便当盒布局 | Bento Grid | 模块化卡片、网格排列 | 数据展示、功能聚合页 |
| 暗黑模式 | Dark Mode | 深色背景、低亮度护眼 | 开发工具、影音类 |
| 新野兽派 | Neo-Brutalism | 粗黑边框、高对比、大胆配色 | 创意类、潮流品牌 |
**默认值**现代极简Minimalism
[配色选项]
| 选项 | 说明 |
|------|------|
| 浅色系 | 白色/浅灰背景,深色文字 |
| 深色系 | 深色/黑色背景,浅色文字 |
| 指定主色 | 用户指定品牌色或主题色 |
**默认值**:浅色系
[目标平台选项]
| 选项 | 说明 |
|------|------|
| 桌面端 | Desktop application宽屏布局 |
| 网页 | Web application响应式布局 |
| 移动端 | Mobile application竖屏布局 |
**默认值**:网页
[工作流程]
[启动阶段]
目的:读取 Product-Spec.md提取信息补充缺失的视觉参数
第一步:检测文件
检测项目目录中是否存在 Product-Spec.md
不存在 → 提示:「未找到 Product-Spec.md请先使用 /prd 完成需求收集。」,终止流程
存在 → 继续
第二步:解析 Product-Spec.md
读取 Product-Spec.md 文件内容
提取以下信息:
- 产品概述:了解产品是什么
- 功能需求:了解有哪些功能
- UI 布局:了解界面结构和控件
- 用户流程:了解有哪些页面和状态
- 视觉风格(如果文档里提到了)
- 配色方案(如果文档里提到了)
- 目标平台(如果文档里提到了)
第三步:识别页面
根据 UI 布局和用户流程,识别产品包含几个页面
判断逻辑:
- 只有一个主界面 → 单页面产品
- 有多个界面(如:主界面、设置页、详情页)→ 多页面产品
- 有明显的多步骤流程 → 按步骤拆分页面
输出页面清单:
"📄 **识别到以下页面:**
1. [页面名称][简要描述]
2. [页面名称][简要描述]
..."
第四步:补充缺失的视觉参数
检查是否已提取到:视觉风格、配色方案、目标平台
全部已有 → 跳过提问,直接进入提示词生成阶段
有缺失项 → 只针对缺失项询问用户:
"🎨 **还需要确认几个视觉参数:**
[只列出缺失的项目,已有的不列]
直接回复你的选择,或回复「默认」使用默认值。"
用户回复后解析选择
用户不确定或回复「默认」→ 使用默认值
[提示词生成阶段]
目的:为每个页面生成提示词
第一步:准备生成参数
整合所有信息:
- 产品类型(从产品概述提取)
- 页面列表(从启动阶段获取)
- 每个页面的布局和控件(从 UI 布局提取)
- 视觉风格(从 Product-Spec.md 提取或用户选择)
- 配色方案(从 Product-Spec.md 提取或用户选择)
- 目标平台(从 Product-Spec.md 提取或用户选择)
第二步:按页面生成提示词
加载 templates/ui-prompt-template.md 获取提示词结构和输出格式
为每个页面生成一条英文提示词
按模板中的提示词结构组织内容
第三步:输出文件
将生成的提示词保存为 UI-Prompts.md
[初始化]
执行 [启动阶段]

View File

@@ -0,0 +1,154 @@
---
name: ui-prompt-template
description: UI 原型图提示词输出模板。当需要生成文生图提示词时,按照此模板的结构和格式填充内容,输出为 UI-Prompts.md 文件。
---
# UI 原型图提示词模板
本模板用于生成可直接用于 AI 绘图工具的原型图提示词。生成时按照此结构填充内容。
---
## 文件命名
`UI-Prompts.md`
---
## 提示词结构
每条提示词按以下结构组织:
```
[主体] + [布局] + [控件] + [风格] + [质量词]
```
### [主体]
产品类型 + 界面类型 + 页面名称
示例:
- `A modern web application UI for a storyboard generator tool, main interface`
- `A mobile app screen for a task management application, settings page`
### [布局]
整体结构 + 比例 + 区域划分
示例:
- `split layout with left panel (40%) and right content area (60%)`
- `single column layout with top navigation bar and main content below`
- `grid layout with 2x2 card arrangement`
### [控件]
各区域的具体控件,从上到下、从左到右描述
示例:
- `left panel contains: project name input at top, large text area for content, dropdown menu for style selection, primary action button at bottom`
- `right panel shows: 3x3 grid of image cards with frame numbers and captions, action buttons below`
### [风格]
视觉风格 + 配色 + 细节特征
| 风格 | 英文描述 |
|------|---------|
| 现代极简 | minimalist design, clean layout, ample white space, subtle shadows |
| 玻璃拟态 | glassmorphism style, frosted glass effect, translucent panels, blur background |
| 新拟态 | neumorphism design, soft shadows, subtle highlights, extruded elements |
| 便当盒布局 | bento grid layout, modular cards, organized sections, clean borders |
| 暗黑模式 | dark mode UI, dark background, light text, subtle glow effects |
| 新野兽派 | neo-brutalist design, bold black borders, high contrast, raw aesthetic |
配色描述:
- 浅色系:`light color scheme, white background, dark text, [accent color] accent`
- 深色系:`dark color scheme, dark gray background, light text, [accent color] accent`
### [质量词]
确保生成质量的关键词,放在提示词末尾
```
UI/UX design, high fidelity mockup, 4K resolution, professional, Figma style, dribbble, behance
```
---
## 输出格式
```markdown
# [产品名称] 原型图提示词
> 视觉风格:[风格名称]
> 配色方案:[配色名称]
> 目标平台:[平台名称]
---
## 页面 1[页面名称]
**页面说明**[一句话描述这个页面是什么]
**提示词**
```
[完整的英文提示词]
```
---
## 页面 2[页面名称]
**页面说明**[一句话描述]
**提示词**
```
[完整的英文提示词]
```
```
---
## 完整示例
以下是「剧本分镜生成器」的原型图提示词示例,供参考:
```markdown
# 剧本分镜生成器 原型图提示词
> 视觉风格现代极简Minimalism
> 配色方案:浅色系
> 目标平台网页Web
---
## 页面 1主界面
**页面说明**:用户输入剧本、设置角色和场景、生成分镜图的主要工作界面
**提示词**
```
A modern web application UI for a storyboard generator tool, main interface, split layout with left input panel (40% width) and right output area (60% width), left panel contains: project name input field at top, large multiline text area for script input with placeholder text, character cards section with image thumbnails and text fields and add button, scene cards section below, style dropdown menu, prominent generate button at bottom, right panel shows: 3x3 grid of storyboard image cards with frame numbers and short descriptions below each image, download all button and continue generating button below the grid, page navigation at bottom, minimalist design, clean layout, white background, light gray borders, blue accent color for primary actions, subtle shadows, rounded corners, UI/UX design, high fidelity mockup, 4K resolution, professional, Figma style
```
---
## 页面 2空状态界面
**页面说明**:用户首次打开、尚未输入内容时的引导界面
**提示词**
```
A modern web application UI for a storyboard generator tool, empty state screen, split layout with left panel (40%) and right panel (60%), left panel shows: empty input fields with placeholder text and helper icons, right panel displays: large empty state illustration in the center, welcome message and getting started tips below, minimalist design, clean layout, white background, soft gray placeholder elements, blue accent color, friendly and inviting atmosphere, UI/UX design, high fidelity mockup, 4K resolution, professional, Figma style
```
```
---
## 写作要点
1. **提示词语言**始终使用英文AI 绘图工具对英文理解更好
2. **结构完整**:确保包含主体、布局、控件、风格、质量词五个部分
3. **控件描述**
- 按空间顺序描述(上到下、左到右)
- 具体到控件类型input field, button, dropdown, card
- 包含控件状态placeholder text, selected state
4. **布局比例**写明具体比例40%/60%),不要只说「左右布局」
5. **风格一致**:同一产品的多个页面使用相同的风格描述
6. **质量词**:始终在末尾加上质量词确保生成效果
7. **页面说明**:用中文写一句话说明,帮助理解这个页面是什么

View File

@@ -0,0 +1,242 @@
# Verification Loop Skill
Comprehensive verification system for Python/FastAPI development.
## When to Use
Invoke this skill:
- After completing a feature or significant code change
- Before creating a PR
- When you want to ensure quality gates pass
- After refactoring
- Before deployment
## Verification Phases
### Phase 1: Type Check
```bash
# Run mypy type checker
mypy src/ --ignore-missing-imports 2>&1 | head -30
```
Report all type errors. Fix critical ones before continuing.
### Phase 2: Lint Check
```bash
# Run ruff linter
ruff check src/ 2>&1 | head -30
# Auto-fix if desired
ruff check src/ --fix
```
Check for:
- Unused imports
- Code style violations
- Common Python anti-patterns
### Phase 3: Test Suite
```bash
# Run tests with coverage
pytest --cov=src --cov-report=term-missing -q 2>&1 | tail -50
# Run specific test file
pytest tests/test_ocr/test_machine_code_parser.py -v
# Run with short traceback
pytest -x --tb=short
```
Report:
- Total tests: X
- Passed: X
- Failed: X
- Coverage: X%
- Target: 80% minimum
### Phase 4: Security Scan
```bash
# Check for hardcoded secrets
grep -rn "password\s*=" --include="*.py" src/ 2>/dev/null | grep -v "db_password:" | head -10
grep -rn "api_key\s*=" --include="*.py" src/ 2>/dev/null | head -10
grep -rn "sk-" --include="*.py" src/ 2>/dev/null | head -10
# Check for print statements (should use logging)
grep -rn "print(" --include="*.py" src/ 2>/dev/null | head -10
# Check for bare except
grep -rn "except:" --include="*.py" src/ 2>/dev/null | head -10
# Check for SQL injection risks (f-strings in execute)
grep -rn 'execute(f"' --include="*.py" src/ 2>/dev/null | head -10
grep -rn "execute(f'" --include="*.py" src/ 2>/dev/null | head -10
```
### Phase 5: Import Check
```bash
# Verify all imports work
python -c "from src.web.app import app; print('Web app OK')"
python -c "from src.inference.pipeline import InferencePipeline; print('Pipeline OK')"
python -c "from src.ocr.machine_code_parser import parse_payment_line; print('Parser OK')"
```
### Phase 6: Diff Review
```bash
# Show what changed
git diff --stat
git diff HEAD --name-only
# Show staged changes
git diff --staged --stat
```
Review each changed file for:
- Unintended changes
- Missing error handling
- Potential edge cases
- Missing type hints
- Mutable default arguments
### Phase 7: API Smoke Test (if server running)
```bash
# Health check
curl -s http://localhost:8000/api/v1/health | python -m json.tool
# Verify response format
curl -s http://localhost:8000/api/v1/health | grep -q "healthy" && echo "Health: OK" || echo "Health: FAIL"
```
## Output Format
After running all phases, produce a verification report:
```
VERIFICATION REPORT
==================
Types: [PASS/FAIL] (X errors)
Lint: [PASS/FAIL] (X warnings)
Tests: [PASS/FAIL] (X/Y passed, Z% coverage)
Security: [PASS/FAIL] (X issues)
Imports: [PASS/FAIL]
Diff: [X files changed]
Overall: [READY/NOT READY] for PR
Issues to Fix:
1. ...
2. ...
```
## Quick Commands
```bash
# Full verification (WSL)
wsl bash -c "source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && mypy src/ --ignore-missing-imports && ruff check src/ && pytest -x --tb=short"
# Type check only
wsl bash -c "source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && mypy src/ --ignore-missing-imports"
# Tests only
wsl bash -c "source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && pytest --cov=src -q"
```
## Verification Checklist
### Before Commit
- [ ] mypy passes (no type errors)
- [ ] ruff check passes (no lint errors)
- [ ] All tests pass
- [ ] No print() statements in production code
- [ ] No hardcoded secrets
- [ ] No bare `except:` clauses
- [ ] No SQL injection risks (f-strings in queries)
- [ ] Coverage >= 80% for changed code
### Before PR
- [ ] All above checks pass
- [ ] git diff reviewed for unintended changes
- [ ] New code has tests
- [ ] Type hints on all public functions
- [ ] Docstrings on public APIs
- [ ] No TODO/FIXME for critical items
### Before Deployment
- [ ] All above checks pass
- [ ] E2E tests pass
- [ ] Health check returns healthy
- [ ] Model loaded successfully
- [ ] No server errors in logs
## Common Issues and Fixes
### Type Error: Missing return type
```python
# Before
def process(data):
return result
# After
def process(data: dict) -> InferenceResult:
return result
```
### Lint Error: Unused import
```python
# Remove unused imports or add to __all__
```
### Security: print() in production
```python
# Before
print(f"Processing {doc_id}")
# After
logger.info(f"Processing {doc_id}")
```
### Security: Bare except
```python
# Before
except:
pass
# After
except Exception as e:
logger.error(f"Error: {e}")
raise
```
### Security: SQL injection
```python
# Before (DANGEROUS)
cur.execute(f"SELECT * FROM docs WHERE id = '{user_input}'")
# After (SAFE)
cur.execute("SELECT * FROM docs WHERE id = %s", (user_input,))
```
## Continuous Mode
For long sessions, run verification after major changes:
```markdown
Checkpoints:
- After completing each function
- After finishing a module
- Before moving to next task
- Every 15-20 minutes of coding
Run: /verify
```
## Integration with Other Skills
| Skill | Purpose |
|-------|---------|
| code-review | Detailed code analysis |
| security-review | Deep security audit |
| tdd-workflow | Test coverage |
| build-fix | Fix errors incrementally |
This skill provides quick, comprehensive verification. Use specialized skills for deeper analysis.

110
.gitignore vendored Normal file
View File

@@ -0,0 +1,110 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
venv/
ENV/
env/
.venv
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Environment variables
.env
.env.local
.env.*.local
!.env.example
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
logs/
# Testing
.coverage
.pytest_cache/
htmlcov/
.tox/
.nox/
# MyPy
.mypy_cache/
.dmypy.json
dmypy.json
# Terraform
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
!.tfvars.example
.terraform.lock.hcl
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Build outputs
dist/
dist-ssr/
build/
*.local
# TypeScript
*.tsbuildinfo
# Vite
.vite/
# Coverage
coverage/
# Temporary files
*.tmp
*.temp
.cache/
# Azure
azure_credentials.json
# Secrets
*.pem
*.key
secrets/
# OS
Thumbs.db

View File

@@ -0,0 +1,314 @@
# Backend Development Patterns
Backend architecture patterns for Python/FastAPI/PostgreSQL applications.
## API Design
### RESTful Structure
```
GET /api/v1/documents # List
GET /api/v1/documents/{id} # Get
POST /api/v1/documents # Create
PUT /api/v1/documents/{id} # Replace
PATCH /api/v1/documents/{id} # Update
DELETE /api/v1/documents/{id} # Delete
GET /api/v1/documents?status=processed&sort=created_at&limit=20&offset=0
```
### FastAPI Route Pattern
```python
from fastapi import APIRouter, HTTPException, Depends, Query, File, UploadFile
from pydantic import BaseModel
router = APIRouter(prefix="/api/v1", tags=["inference"])
@router.post("/infer", response_model=ApiResponse[InferenceResult])
async def infer_document(
file: UploadFile = File(...),
confidence_threshold: float = Query(0.5, ge=0, le=1),
service: InferenceService = Depends(get_inference_service)
) -> ApiResponse[InferenceResult]:
result = await service.process(file, confidence_threshold)
return ApiResponse(success=True, data=result)
```
### Consistent Response Schema
```python
from typing import Generic, TypeVar
T = TypeVar('T')
class ApiResponse(BaseModel, Generic[T]):
success: bool
data: T | None = None
error: str | None = None
meta: dict | None = None
```
## Core Patterns
### Repository Pattern
```python
from typing import Protocol
class DocumentRepository(Protocol):
def find_all(self, filters: dict | None = None) -> list[Document]: ...
def find_by_id(self, id: str) -> Document | None: ...
def create(self, data: dict) -> Document: ...
def update(self, id: str, data: dict) -> Document: ...
def delete(self, id: str) -> None: ...
```
### Service Layer
```python
class InferenceService:
def __init__(self, model_path: str, use_gpu: bool = True):
self.pipeline = InferencePipeline(model_path=model_path, use_gpu=use_gpu)
async def process(self, file: UploadFile, confidence_threshold: float) -> InferenceResult:
temp_path = self._save_temp_file(file)
try:
return self.pipeline.process_pdf(temp_path)
finally:
temp_path.unlink(missing_ok=True)
```
### Dependency Injection
```python
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
db_host: str = "localhost"
db_password: str
model_path: str = "runs/train/invoice_fields/weights/best.pt"
class Config:
env_file = ".env"
@lru_cache()
def get_settings() -> Settings:
return Settings()
def get_inference_service(settings: Settings = Depends(get_settings)) -> InferenceService:
return InferenceService(model_path=settings.model_path)
```
## Database Patterns
### Connection Pooling
```python
from psycopg2 import pool
from contextlib import contextmanager
db_pool = pool.ThreadedConnectionPool(minconn=2, maxconn=10, **db_config)
@contextmanager
def get_db_connection():
conn = db_pool.getconn()
try:
yield conn
finally:
db_pool.putconn(conn)
```
### Query Optimization
```python
# GOOD: Select only needed columns
cur.execute("""
SELECT id, status, fields->>'InvoiceNumber' as invoice_number
FROM documents WHERE status = %s
ORDER BY created_at DESC LIMIT %s
""", ('processed', 10))
# BAD: SELECT * FROM documents
```
### N+1 Prevention
```python
# BAD: N+1 queries
for doc in documents:
doc.labels = get_labels(doc.id) # N queries
# GOOD: Batch fetch with JOIN
cur.execute("""
SELECT d.id, d.status, array_agg(l.label) as labels
FROM documents d
LEFT JOIN document_labels l ON d.id = l.document_id
GROUP BY d.id, d.status
""")
```
### Transaction Pattern
```python
def create_document_with_labels(doc_data: dict, labels: list[dict]) -> str:
with get_db_connection() as conn:
try:
with conn.cursor() as cur:
cur.execute("INSERT INTO documents ... RETURNING id", ...)
doc_id = cur.fetchone()[0]
for label in labels:
cur.execute("INSERT INTO document_labels ...", ...)
conn.commit()
return doc_id
except Exception:
conn.rollback()
raise
```
## Caching
```python
from cachetools import TTLCache
_cache = TTLCache(maxsize=1000, ttl=300)
def get_document_cached(doc_id: str) -> Document | None:
if doc_id in _cache:
return _cache[doc_id]
doc = repo.find_by_id(doc_id)
if doc:
_cache[doc_id] = doc
return doc
```
## Error Handling
### Exception Hierarchy
```python
class AppError(Exception):
def __init__(self, message: str, status_code: int = 500):
self.message = message
self.status_code = status_code
class NotFoundError(AppError):
def __init__(self, resource: str, id: str):
super().__init__(f"{resource} not found: {id}", 404)
class ValidationError(AppError):
def __init__(self, message: str):
super().__init__(message, 400)
```
### FastAPI Exception Handler
```python
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
return JSONResponse(status_code=exc.status_code, content={"success": False, "error": exc.message})
@app.exception_handler(Exception)
async def generic_error_handler(request: Request, exc: Exception):
logger.error(f"Unexpected error: {exc}", exc_info=True)
return JSONResponse(status_code=500, content={"success": False, "error": "Internal server error"})
```
### Retry with Backoff
```python
async def retry_with_backoff(fn, max_retries: int = 3, base_delay: float = 1.0):
last_error = None
for attempt in range(max_retries):
try:
return await fn() if asyncio.iscoroutinefunction(fn) else fn()
except Exception as e:
last_error = e
if attempt < max_retries - 1:
await asyncio.sleep(base_delay * (2 ** attempt))
raise last_error
```
## Rate Limiting
```python
from time import time
from collections import defaultdict
class RateLimiter:
def __init__(self):
self.requests: dict[str, list[float]] = defaultdict(list)
def check_limit(self, identifier: str, max_requests: int, window_sec: int) -> bool:
now = time()
self.requests[identifier] = [t for t in self.requests[identifier] if now - t < window_sec]
if len(self.requests[identifier]) >= max_requests:
return False
self.requests[identifier].append(now)
return True
limiter = RateLimiter()
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
ip = request.client.host
if not limiter.check_limit(ip, max_requests=100, window_sec=60):
return JSONResponse(status_code=429, content={"error": "Rate limit exceeded"})
return await call_next(request)
```
## Logging & Middleware
### Request Logging
```python
@app.middleware("http")
async def log_requests(request: Request, call_next):
request_id = str(uuid.uuid4())[:8]
start_time = time.time()
logger.info(f"[{request_id}] {request.method} {request.url.path}")
response = await call_next(request)
duration_ms = (time.time() - start_time) * 1000
logger.info(f"[{request_id}] Completed {response.status_code} in {duration_ms:.2f}ms")
return response
```
### Structured Logging
```python
class JSONFormatter(logging.Formatter):
def format(self, record):
return json.dumps({
"timestamp": datetime.utcnow().isoformat(),
"level": record.levelname,
"message": record.getMessage(),
"module": record.module,
})
```
## Background Tasks
```python
from fastapi import BackgroundTasks
def send_notification(document_id: str, status: str):
logger.info(f"Notification: {document_id} -> {status}")
@router.post("/infer")
async def infer(file: UploadFile, background_tasks: BackgroundTasks):
result = await process_document(file)
background_tasks.add_task(send_notification, result.document_id, "completed")
return result
```
## Key Principles
- Repository pattern: Abstract data access
- Service layer: Business logic separated from routes
- Dependency injection via `Depends()`
- Connection pooling for database
- Parameterized queries only (no f-strings in SQL)
- Batch fetch to prevent N+1
- Consistent `ApiResponse[T]` format
- Exception hierarchy with proper status codes
- Rate limit by IP
- Structured logging with request ID

View File

@@ -0,0 +1,665 @@
---
name: coding-standards
description: Universal coding standards, best practices, and patterns for Python, FastAPI, and data processing development.
---
# Coding Standards & Best Practices
Python coding standards for the Invoice Master project.
## Code Quality Principles
### 1. Readability First
- Code is read more than written
- Clear variable and function names
- Self-documenting code preferred over comments
- Consistent formatting (follow PEP 8)
### 2. KISS (Keep It Simple, Stupid)
- Simplest solution that works
- Avoid over-engineering
- No premature optimization
- Easy to understand > clever code
### 3. DRY (Don't Repeat Yourself)
- Extract common logic into functions
- Create reusable utilities
- Share modules across the codebase
- Avoid copy-paste programming
### 4. YAGNI (You Aren't Gonna Need It)
- Don't build features before they're needed
- Avoid speculative generality
- Add complexity only when required
- Start simple, refactor when needed
## Python Standards
### Variable Naming
```python
# GOOD: Descriptive names
invoice_number = "INV-2024-001"
is_valid_document = True
total_confidence_score = 0.95
# BAD: Unclear names
inv = "INV-2024-001"
flag = True
x = 0.95
```
### Function Naming
```python
# GOOD: Verb-noun pattern with type hints
def extract_invoice_fields(pdf_path: Path) -> dict[str, str]:
"""Extract fields from invoice PDF."""
...
def calculate_confidence(predictions: list[float]) -> float:
"""Calculate average confidence score."""
...
def is_valid_bankgiro(value: str) -> bool:
"""Check if value is valid Bankgiro number."""
...
# BAD: Unclear or noun-only
def invoice(path):
...
def confidence(p):
...
def bankgiro(v):
...
```
### Type Hints (REQUIRED)
```python
# GOOD: Full type annotations
from typing import Optional
from pathlib import Path
from dataclasses import dataclass
@dataclass
class InferenceResult:
document_id: str
fields: dict[str, str]
confidence: dict[str, float]
processing_time_ms: float
def process_document(
pdf_path: Path,
confidence_threshold: float = 0.5
) -> InferenceResult:
"""Process PDF and return extracted fields."""
...
# BAD: No type hints
def process_document(pdf_path, confidence_threshold=0.5):
...
```
### Immutability Pattern (CRITICAL)
```python
# GOOD: Create new objects, don't mutate
def update_fields(fields: dict[str, str], updates: dict[str, str]) -> dict[str, str]:
return {**fields, **updates}
def add_item(items: list[str], new_item: str) -> list[str]:
return [*items, new_item]
# BAD: Direct mutation
def update_fields(fields: dict[str, str], updates: dict[str, str]) -> dict[str, str]:
fields.update(updates) # MUTATION!
return fields
def add_item(items: list[str], new_item: str) -> list[str]:
items.append(new_item) # MUTATION!
return items
```
### Error Handling
```python
import logging
logger = logging.getLogger(__name__)
# GOOD: Comprehensive error handling with logging
def load_model(model_path: Path) -> Model:
"""Load YOLO model from path."""
try:
if not model_path.exists():
raise FileNotFoundError(f"Model not found: {model_path}")
model = YOLO(str(model_path))
logger.info(f"Model loaded: {model_path}")
return model
except Exception as e:
logger.error(f"Failed to load model: {e}")
raise RuntimeError(f"Model loading failed: {model_path}") from e
# BAD: No error handling
def load_model(model_path):
return YOLO(str(model_path))
# BAD: Bare except
def load_model(model_path):
try:
return YOLO(str(model_path))
except: # Never use bare except!
return None
```
### Async Best Practices
```python
import asyncio
# GOOD: Parallel execution when possible
async def process_batch(pdf_paths: list[Path]) -> list[InferenceResult]:
tasks = [process_document(path) for path in pdf_paths]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Handle exceptions
valid_results = []
for path, result in zip(pdf_paths, results):
if isinstance(result, Exception):
logger.error(f"Failed to process {path}: {result}")
else:
valid_results.append(result)
return valid_results
# BAD: Sequential when unnecessary
async def process_batch(pdf_paths: list[Path]) -> list[InferenceResult]:
results = []
for path in pdf_paths:
result = await process_document(path)
results.append(result)
return results
```
### Context Managers
```python
from contextlib import contextmanager
from pathlib import Path
import tempfile
# GOOD: Proper resource management
@contextmanager
def temp_pdf_copy(pdf_path: Path):
"""Create temporary copy of PDF for processing."""
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp.write(pdf_path.read_bytes())
tmp_path = Path(tmp.name)
try:
yield tmp_path
finally:
tmp_path.unlink(missing_ok=True)
# Usage
with temp_pdf_copy(original_pdf) as tmp_pdf:
result = process_pdf(tmp_pdf)
```
## FastAPI Best Practices
### Route Structure
```python
from fastapi import APIRouter, HTTPException, Depends, Query, File, UploadFile
from pydantic import BaseModel
router = APIRouter(prefix="/api/v1", tags=["inference"])
class InferenceResponse(BaseModel):
success: bool
document_id: str
fields: dict[str, str]
confidence: dict[str, float]
processing_time_ms: float
@router.post("/infer", response_model=InferenceResponse)
async def infer_document(
file: UploadFile = File(...),
confidence_threshold: float = Query(0.5, ge=0.0, le=1.0)
) -> InferenceResponse:
"""Process invoice PDF and extract fields."""
if not file.filename.endswith(".pdf"):
raise HTTPException(status_code=400, detail="Only PDF files accepted")
result = await inference_service.process(file, confidence_threshold)
return InferenceResponse(
success=True,
document_id=result.document_id,
fields=result.fields,
confidence=result.confidence,
processing_time_ms=result.processing_time_ms
)
```
### Input Validation with Pydantic
```python
from pydantic import BaseModel, Field, field_validator
from datetime import date
import re
class InvoiceData(BaseModel):
invoice_number: str = Field(..., min_length=1, max_length=50)
invoice_date: date
amount: float = Field(..., gt=0)
bankgiro: str | None = None
ocr_number: str | None = None
@field_validator("bankgiro")
@classmethod
def validate_bankgiro(cls, v: str | None) -> str | None:
if v is None:
return None
# Bankgiro: 7-8 digits
cleaned = re.sub(r"[^0-9]", "", v)
if not (7 <= len(cleaned) <= 8):
raise ValueError("Bankgiro must be 7-8 digits")
return cleaned
@field_validator("ocr_number")
@classmethod
def validate_ocr(cls, v: str | None) -> str | None:
if v is None:
return None
# OCR: 2-25 digits
cleaned = re.sub(r"[^0-9]", "", v)
if not (2 <= len(cleaned) <= 25):
raise ValueError("OCR must be 2-25 digits")
return cleaned
```
### Response Format
```python
from pydantic import BaseModel
from typing import Generic, TypeVar
T = TypeVar("T")
class ApiResponse(BaseModel, Generic[T]):
success: bool
data: T | None = None
error: str | None = None
meta: dict | None = None
# Success response
return ApiResponse(
success=True,
data=result,
meta={"processing_time_ms": elapsed_ms}
)
# Error response
return ApiResponse(
success=False,
error="Invalid PDF format"
)
```
## File Organization
### Project Structure
```
src/
├── cli/ # Command-line interfaces
│ ├── autolabel.py
│ ├── train.py
│ └── infer.py
├── pdf/ # PDF processing
│ ├── extractor.py
│ └── renderer.py
├── ocr/ # OCR processing
│ ├── paddle_ocr.py
│ └── machine_code_parser.py
├── inference/ # Inference pipeline
│ ├── pipeline.py
│ ├── yolo_detector.py
│ └── field_extractor.py
├── normalize/ # Field normalization
│ ├── base.py
│ ├── date_normalizer.py
│ └── amount_normalizer.py
├── web/ # FastAPI application
│ ├── app.py
│ ├── routes.py
│ ├── services.py
│ └── schemas.py
└── utils/ # Shared utilities
├── validators.py
├── text_cleaner.py
└── logging.py
tests/ # Mirror of src structure
├── test_pdf/
├── test_ocr/
└── test_inference/
```
### File Naming
```
src/ocr/paddle_ocr.py # snake_case for modules
src/inference/yolo_detector.py # snake_case for modules
tests/test_paddle_ocr.py # test_ prefix for tests
config.py # snake_case for config
```
### Module Size Guidelines
- **Maximum**: 800 lines per file
- **Typical**: 200-400 lines per file
- **Functions**: Max 50 lines each
- Extract utilities when modules grow too large
## Comments & Documentation
### When to Comment
```python
# GOOD: Explain WHY, not WHAT
# Swedish Bankgiro uses Luhn algorithm with weight [1,2,1,2...]
def validate_bankgiro_checksum(bankgiro: str) -> bool:
...
# Payment line format: 7 groups separated by #, checksum at end
def parse_payment_line(line: str) -> PaymentLineData:
...
# BAD: Stating the obvious
# Increment counter by 1
count += 1
# Set name to user's name
name = user.name
```
### Docstrings for Public APIs
```python
def extract_invoice_fields(
pdf_path: Path,
confidence_threshold: float = 0.5,
use_gpu: bool = True
) -> InferenceResult:
"""Extract structured fields from Swedish invoice PDF.
Uses YOLOv11 for field detection and PaddleOCR for text extraction.
Applies field-specific normalization and validation.
Args:
pdf_path: Path to the invoice PDF file.
confidence_threshold: Minimum confidence for field detection (0.0-1.0).
use_gpu: Whether to use GPU acceleration.
Returns:
InferenceResult containing extracted fields and confidence scores.
Raises:
FileNotFoundError: If PDF file doesn't exist.
ProcessingError: If OCR or detection fails.
Example:
>>> result = extract_invoice_fields(Path("invoice.pdf"))
>>> print(result.fields["invoice_number"])
"INV-2024-001"
"""
...
```
## Performance Best Practices
### Caching
```python
from functools import lru_cache
from cachetools import TTLCache
# Static data: LRU cache
@lru_cache(maxsize=100)
def get_field_config(field_name: str) -> FieldConfig:
"""Load field configuration (cached)."""
return load_config(field_name)
# Dynamic data: TTL cache
_document_cache = TTLCache(maxsize=1000, ttl=300) # 5 minutes
def get_document_cached(doc_id: str) -> Document | None:
if doc_id in _document_cache:
return _document_cache[doc_id]
doc = repo.find_by_id(doc_id)
if doc:
_document_cache[doc_id] = doc
return doc
```
### Database Queries
```python
# GOOD: Select only needed columns
cur.execute("""
SELECT id, status, fields->>'invoice_number'
FROM documents
WHERE status = %s
LIMIT %s
""", ('processed', 10))
# BAD: Select everything
cur.execute("SELECT * FROM documents")
# GOOD: Batch operations
cur.executemany(
"INSERT INTO labels (doc_id, field, value) VALUES (%s, %s, %s)",
[(doc_id, f, v) for f, v in fields.items()]
)
# BAD: Individual inserts in loop
for field, value in fields.items():
cur.execute("INSERT INTO labels ...", (doc_id, field, value))
```
### Lazy Loading
```python
class InferencePipeline:
def __init__(self, model_path: Path):
self.model_path = model_path
self._model: YOLO | None = None
self._ocr: PaddleOCR | None = None
@property
def model(self) -> YOLO:
"""Lazy load YOLO model."""
if self._model is None:
self._model = YOLO(str(self.model_path))
return self._model
@property
def ocr(self) -> PaddleOCR:
"""Lazy load PaddleOCR."""
if self._ocr is None:
self._ocr = PaddleOCR(use_angle_cls=True, lang="latin")
return self._ocr
```
## Testing Standards
### Test Structure (AAA Pattern)
```python
def test_extract_bankgiro_valid():
# Arrange
text = "Bankgiro: 123-4567"
# Act
result = extract_bankgiro(text)
# Assert
assert result == "1234567"
def test_extract_bankgiro_invalid_returns_none():
# Arrange
text = "No bankgiro here"
# Act
result = extract_bankgiro(text)
# Assert
assert result is None
```
### Test Naming
```python
# GOOD: Descriptive test names
def test_parse_payment_line_extracts_all_fields(): ...
def test_parse_payment_line_handles_missing_checksum(): ...
def test_validate_ocr_returns_false_for_invalid_checksum(): ...
# BAD: Vague test names
def test_parse(): ...
def test_works(): ...
def test_payment_line(): ...
```
### Fixtures
```python
import pytest
from pathlib import Path
@pytest.fixture
def sample_invoice_pdf(tmp_path: Path) -> Path:
"""Create sample invoice PDF for testing."""
pdf_path = tmp_path / "invoice.pdf"
# Create test PDF...
return pdf_path
@pytest.fixture
def inference_pipeline(sample_model_path: Path) -> InferencePipeline:
"""Create inference pipeline with test model."""
return InferencePipeline(sample_model_path)
def test_process_invoice(inference_pipeline, sample_invoice_pdf):
result = inference_pipeline.process(sample_invoice_pdf)
assert result.fields.get("invoice_number") is not None
```
## Code Smell Detection
### 1. Long Functions
```python
# BAD: Function > 50 lines
def process_document():
# 100 lines of code...
# GOOD: Split into smaller functions
def process_document(pdf_path: Path) -> InferenceResult:
image = render_pdf(pdf_path)
detections = detect_fields(image)
ocr_results = extract_text(image, detections)
fields = normalize_fields(ocr_results)
return build_result(fields)
```
### 2. Deep Nesting
```python
# BAD: 5+ levels of nesting
if document:
if document.is_valid:
if document.has_fields:
if field in document.fields:
if document.fields[field]:
# Do something
# GOOD: Early returns
if not document:
return None
if not document.is_valid:
return None
if not document.has_fields:
return None
if field not in document.fields:
return None
if not document.fields[field]:
return None
# Do something
```
### 3. Magic Numbers
```python
# BAD: Unexplained numbers
if confidence > 0.5:
...
time.sleep(3)
# GOOD: Named constants
CONFIDENCE_THRESHOLD = 0.5
RETRY_DELAY_SECONDS = 3
if confidence > CONFIDENCE_THRESHOLD:
...
time.sleep(RETRY_DELAY_SECONDS)
```
### 4. Mutable Default Arguments
```python
# BAD: Mutable default argument
def process_fields(fields: list = []): # DANGEROUS!
fields.append("new_field")
return fields
# GOOD: Use None as default
def process_fields(fields: list | None = None) -> list:
if fields is None:
fields = []
return [*fields, "new_field"]
```
## Logging Standards
```python
import logging
# Module-level logger
logger = logging.getLogger(__name__)
# GOOD: Appropriate log levels
logger.debug("Processing document: %s", doc_id)
logger.info("Document processed successfully: %s", doc_id)
logger.warning("Low confidence score: %.2f", confidence)
logger.error("Failed to process document: %s", error)
# GOOD: Structured logging with extra data
logger.info(
"Inference complete",
extra={
"document_id": doc_id,
"field_count": len(fields),
"processing_time_ms": elapsed_ms
}
)
# BAD: Using print()
print(f"Processing {doc_id}") # Never in production!
```
**Remember**: Code quality is not negotiable. Clear, maintainable Python code with proper type hints enables confident development and refactoring.

View File

@@ -0,0 +1,80 @@
---
name: continuous-learning
description: Automatically extract reusable patterns from Claude Code sessions and save them as learned skills for future use.
---
# Continuous Learning Skill
Automatically evaluates Claude Code sessions on end to extract reusable patterns that can be saved as learned skills.
## How It Works
This skill runs as a **Stop hook** at the end of each session:
1. **Session Evaluation**: Checks if session has enough messages (default: 10+)
2. **Pattern Detection**: Identifies extractable patterns from the session
3. **Skill Extraction**: Saves useful patterns to `~/.claude/skills/learned/`
## Configuration
Edit `config.json` to customize:
```json
{
"min_session_length": 10,
"extraction_threshold": "medium",
"auto_approve": false,
"learned_skills_path": "~/.claude/skills/learned/",
"patterns_to_detect": [
"error_resolution",
"user_corrections",
"workarounds",
"debugging_techniques",
"project_specific"
],
"ignore_patterns": [
"simple_typos",
"one_time_fixes",
"external_api_issues"
]
}
```
## Pattern Types
| Pattern | Description |
|---------|-------------|
| `error_resolution` | How specific errors were resolved |
| `user_corrections` | Patterns from user corrections |
| `workarounds` | Solutions to framework/library quirks |
| `debugging_techniques` | Effective debugging approaches |
| `project_specific` | Project-specific conventions |
## Hook Setup
Add to your `~/.claude/settings.json`:
```json
{
"hooks": {
"Stop": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "~/.claude/skills/continuous-learning/evaluate-session.sh"
}]
}]
}
}
```
## Why Stop Hook?
- **Lightweight**: Runs once at session end
- **Non-blocking**: Doesn't add latency to every message
- **Complete context**: Has access to full session transcript
## Related
- [The Longform Guide](https://x.com/affaanmustafa/status/2014040193557471352) - Section on continuous learning
- `/learn` command - Manual pattern extraction mid-session

View File

@@ -0,0 +1,18 @@
{
"min_session_length": 10,
"extraction_threshold": "medium",
"auto_approve": false,
"learned_skills_path": "~/.claude/skills/learned/",
"patterns_to_detect": [
"error_resolution",
"user_corrections",
"workarounds",
"debugging_techniques",
"project_specific"
],
"ignore_patterns": [
"simple_typos",
"one_time_fixes",
"external_api_issues"
]
}

View File

@@ -0,0 +1,60 @@
#!/bin/bash
# Continuous Learning - Session Evaluator
# Runs on Stop hook to extract reusable patterns from Claude Code sessions
#
# Why Stop hook instead of UserPromptSubmit:
# - Stop runs once at session end (lightweight)
# - UserPromptSubmit runs every message (heavy, adds latency)
#
# Hook config (in ~/.claude/settings.json):
# {
# "hooks": {
# "Stop": [{
# "matcher": "*",
# "hooks": [{
# "type": "command",
# "command": "~/.claude/skills/continuous-learning/evaluate-session.sh"
# }]
# }]
# }
# }
#
# Patterns to detect: error_resolution, debugging_techniques, workarounds, project_specific
# Patterns to ignore: simple_typos, one_time_fixes, external_api_issues
# Extracted skills saved to: ~/.claude/skills/learned/
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/config.json"
LEARNED_SKILLS_PATH="${HOME}/.claude/skills/learned"
MIN_SESSION_LENGTH=10
# Load config if exists
if [ -f "$CONFIG_FILE" ]; then
MIN_SESSION_LENGTH=$(jq -r '.min_session_length // 10' "$CONFIG_FILE")
LEARNED_SKILLS_PATH=$(jq -r '.learned_skills_path // "~/.claude/skills/learned/"' "$CONFIG_FILE" | sed "s|~|$HOME|")
fi
# Ensure learned skills directory exists
mkdir -p "$LEARNED_SKILLS_PATH"
# Get transcript path from environment (set by Claude Code)
transcript_path="${CLAUDE_TRANSCRIPT_PATH:-}"
if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then
exit 0
fi
# Count messages in session
message_count=$(grep -c '"type":"user"' "$transcript_path" 2>/dev/null || echo "0")
# Skip short sessions
if [ "$message_count" -lt "$MIN_SESSION_LENGTH" ]; then
echo "[ContinuousLearning] Session too short ($message_count messages), skipping" >&2
exit 0
fi
# Signal to Claude that session should be evaluated for extractable patterns
echo "[ContinuousLearning] Session has $message_count messages - evaluate for extractable patterns" >&2
echo "[ContinuousLearning] Save learned skills to: $LEARNED_SKILLS_PATH" >&2

View File

@@ -0,0 +1,221 @@
# Eval Harness Skill
A formal evaluation framework for Claude Code sessions, implementing eval-driven development (EDD) principles.
## Philosophy
Eval-Driven Development treats evals as the "unit tests of AI development":
- Define expected behavior BEFORE implementation
- Run evals continuously during development
- Track regressions with each change
- Use pass@k metrics for reliability measurement
## Eval Types
### Capability Evals
Test if Claude can do something it couldn't before:
```markdown
[CAPABILITY EVAL: feature-name]
Task: Description of what Claude should accomplish
Success Criteria:
- [ ] Criterion 1
- [ ] Criterion 2
- [ ] Criterion 3
Expected Output: Description of expected result
```
### Regression Evals
Ensure changes don't break existing functionality:
```markdown
[REGRESSION EVAL: feature-name]
Baseline: SHA or checkpoint name
Tests:
- existing-test-1: PASS/FAIL
- existing-test-2: PASS/FAIL
- existing-test-3: PASS/FAIL
Result: X/Y passed (previously Y/Y)
```
## Grader Types
### 1. Code-Based Grader
Deterministic checks using code:
```bash
# Check if file contains expected pattern
grep -q "export function handleAuth" src/auth.ts && echo "PASS" || echo "FAIL"
# Check if tests pass
npm test -- --testPathPattern="auth" && echo "PASS" || echo "FAIL"
# Check if build succeeds
npm run build && echo "PASS" || echo "FAIL"
```
### 2. Model-Based Grader
Use Claude to evaluate open-ended outputs:
```markdown
[MODEL GRADER PROMPT]
Evaluate the following code change:
1. Does it solve the stated problem?
2. Is it well-structured?
3. Are edge cases handled?
4. Is error handling appropriate?
Score: 1-5 (1=poor, 5=excellent)
Reasoning: [explanation]
```
### 3. Human Grader
Flag for manual review:
```markdown
[HUMAN REVIEW REQUIRED]
Change: Description of what changed
Reason: Why human review is needed
Risk Level: LOW/MEDIUM/HIGH
```
## Metrics
### pass@k
"At least one success in k attempts"
- pass@1: First attempt success rate
- pass@3: Success within 3 attempts
- Typical target: pass@3 > 90%
### pass^k
"All k trials succeed"
- Higher bar for reliability
- pass^3: 3 consecutive successes
- Use for critical paths
## Eval Workflow
### 1. Define (Before Coding)
```markdown
## EVAL DEFINITION: feature-xyz
### Capability Evals
1. Can create new user account
2. Can validate email format
3. Can hash password securely
### Regression Evals
1. Existing login still works
2. Session management unchanged
3. Logout flow intact
### Success Metrics
- pass@3 > 90% for capability evals
- pass^3 = 100% for regression evals
```
### 2. Implement
Write code to pass the defined evals.
### 3. Evaluate
```bash
# Run capability evals
[Run each capability eval, record PASS/FAIL]
# Run regression evals
npm test -- --testPathPattern="existing"
# Generate report
```
### 4. Report
```markdown
EVAL REPORT: feature-xyz
========================
Capability Evals:
create-user: PASS (pass@1)
validate-email: PASS (pass@2)
hash-password: PASS (pass@1)
Overall: 3/3 passed
Regression Evals:
login-flow: PASS
session-mgmt: PASS
logout-flow: PASS
Overall: 3/3 passed
Metrics:
pass@1: 67% (2/3)
pass@3: 100% (3/3)
Status: READY FOR REVIEW
```
## Integration Patterns
### Pre-Implementation
```
/eval define feature-name
```
Creates eval definition file at `.claude/evals/feature-name.md`
### During Implementation
```
/eval check feature-name
```
Runs current evals and reports status
### Post-Implementation
```
/eval report feature-name
```
Generates full eval report
## Eval Storage
Store evals in project:
```
.claude/
evals/
feature-xyz.md # Eval definition
feature-xyz.log # Eval run history
baseline.json # Regression baselines
```
## Best Practices
1. **Define evals BEFORE coding** - Forces clear thinking about success criteria
2. **Run evals frequently** - Catch regressions early
3. **Track pass@k over time** - Monitor reliability trends
4. **Use code graders when possible** - Deterministic > probabilistic
5. **Human review for security** - Never fully automate security checks
6. **Keep evals fast** - Slow evals don't get run
7. **Version evals with code** - Evals are first-class artifacts
## Example: Adding Authentication
```markdown
## EVAL: add-authentication
### Phase 1: Define (10 min)
Capability Evals:
- [ ] User can register with email/password
- [ ] User can login with valid credentials
- [ ] Invalid credentials rejected with proper error
- [ ] Sessions persist across page reloads
- [ ] Logout clears session
Regression Evals:
- [ ] Public routes still accessible
- [ ] API responses unchanged
- [ ] Database schema compatible
### Phase 2: Implement (varies)
[Write code]
### Phase 3: Evaluate
Run: /eval check add-authentication
### Phase 4: Report
EVAL REPORT: add-authentication
==============================
Capability: 5/5 passed (pass@3: 100%)
Regression: 3/3 passed (pass^3: 100%)
Status: SHIP IT
```

View File

@@ -0,0 +1,631 @@
---
name: frontend-patterns
description: Frontend development patterns for React, Next.js, state management, performance optimization, and UI best practices.
---
# Frontend Development Patterns
Modern frontend patterns for React, Next.js, and performant user interfaces.
## Component Patterns
### Composition Over Inheritance
```typescript
// ✅ GOOD: Component composition
interface CardProps {
children: React.ReactNode
variant?: 'default' | 'outlined'
}
export function Card({ children, variant = 'default' }: CardProps) {
return <div className={`card card-${variant}`}>{children}</div>
}
export function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="card-header">{children}</div>
}
export function CardBody({ children }: { children: React.ReactNode }) {
return <div className="card-body">{children}</div>
}
// Usage
<Card>
<CardHeader>Title</CardHeader>
<CardBody>Content</CardBody>
</Card>
```
### Compound Components
```typescript
interface TabsContextValue {
activeTab: string
setActiveTab: (tab: string) => void
}
const TabsContext = createContext<TabsContextValue | undefined>(undefined)
export function Tabs({ children, defaultTab }: {
children: React.ReactNode
defaultTab: string
}) {
const [activeTab, setActiveTab] = useState(defaultTab)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
)
}
export function TabList({ children }: { children: React.ReactNode }) {
return <div className="tab-list">{children}</div>
}
export function Tab({ id, children }: { id: string, children: React.ReactNode }) {
const context = useContext(TabsContext)
if (!context) throw new Error('Tab must be used within Tabs')
return (
<button
className={context.activeTab === id ? 'active' : ''}
onClick={() => context.setActiveTab(id)}
>
{children}
</button>
)
}
// Usage
<Tabs defaultTab="overview">
<TabList>
<Tab id="overview">Overview</Tab>
<Tab id="details">Details</Tab>
</TabList>
</Tabs>
```
### Render Props Pattern
```typescript
interface DataLoaderProps<T> {
url: string
children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode
}
export function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false))
}, [url])
return <>{children(data, loading, error)}</>
}
// Usage
<DataLoader<Market[]> url="/api/markets">
{(markets, loading, error) => {
if (loading) return <Spinner />
if (error) return <Error error={error} />
return <MarketList markets={markets!} />
}}
</DataLoader>
```
## Custom Hooks Patterns
### State Management Hook
```typescript
export function useToggle(initialValue = false): [boolean, () => void] {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => {
setValue(v => !v)
}, [])
return [value, toggle]
}
// Usage
const [isOpen, toggleOpen] = useToggle()
```
### Async Data Fetching Hook
```typescript
interface UseQueryOptions<T> {
onSuccess?: (data: T) => void
onError?: (error: Error) => void
enabled?: boolean
}
export function useQuery<T>(
key: string,
fetcher: () => Promise<T>,
options?: UseQueryOptions<T>
) {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
const [loading, setLoading] = useState(false)
const refetch = useCallback(async () => {
setLoading(true)
setError(null)
try {
const result = await fetcher()
setData(result)
options?.onSuccess?.(result)
} catch (err) {
const error = err as Error
setError(error)
options?.onError?.(error)
} finally {
setLoading(false)
}
}, [fetcher, options])
useEffect(() => {
if (options?.enabled !== false) {
refetch()
}
}, [key, refetch, options?.enabled])
return { data, error, loading, refetch }
}
// Usage
const { data: markets, loading, error, refetch } = useQuery(
'markets',
() => fetch('/api/markets').then(r => r.json()),
{
onSuccess: data => console.log('Fetched', data.length, 'markets'),
onError: err => console.error('Failed:', err)
}
)
```
### Debounce Hook
```typescript
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(handler)
}, [value, delay])
return debouncedValue
}
// Usage
const [searchQuery, setSearchQuery] = useState('')
const debouncedQuery = useDebounce(searchQuery, 500)
useEffect(() => {
if (debouncedQuery) {
performSearch(debouncedQuery)
}
}, [debouncedQuery])
```
## State Management Patterns
### Context + Reducer Pattern
```typescript
interface State {
markets: Market[]
selectedMarket: Market | null
loading: boolean
}
type Action =
| { type: 'SET_MARKETS'; payload: Market[] }
| { type: 'SELECT_MARKET'; payload: Market }
| { type: 'SET_LOADING'; payload: boolean }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_MARKETS':
return { ...state, markets: action.payload }
case 'SELECT_MARKET':
return { ...state, selectedMarket: action.payload }
case 'SET_LOADING':
return { ...state, loading: action.payload }
default:
return state
}
}
const MarketContext = createContext<{
state: State
dispatch: Dispatch<Action>
} | undefined>(undefined)
export function MarketProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, {
markets: [],
selectedMarket: null,
loading: false
})
return (
<MarketContext.Provider value={{ state, dispatch }}>
{children}
</MarketContext.Provider>
)
}
export function useMarkets() {
const context = useContext(MarketContext)
if (!context) throw new Error('useMarkets must be used within MarketProvider')
return context
}
```
## Performance Optimization
### Memoization
```typescript
// ✅ useMemo for expensive computations
const sortedMarkets = useMemo(() => {
return markets.sort((a, b) => b.volume - a.volume)
}, [markets])
// ✅ useCallback for functions passed to children
const handleSearch = useCallback((query: string) => {
setSearchQuery(query)
}, [])
// ✅ React.memo for pure components
export const MarketCard = React.memo<MarketCardProps>(({ market }) => {
return (
<div className="market-card">
<h3>{market.name}</h3>
<p>{market.description}</p>
</div>
)
})
```
### Code Splitting & Lazy Loading
```typescript
import { lazy, Suspense } from 'react'
// ✅ Lazy load heavy components
const HeavyChart = lazy(() => import('./HeavyChart'))
const ThreeJsBackground = lazy(() => import('./ThreeJsBackground'))
export function Dashboard() {
return (
<div>
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={data} />
</Suspense>
<Suspense fallback={null}>
<ThreeJsBackground />
</Suspense>
</div>
)
}
```
### Virtualization for Long Lists
```typescript
import { useVirtualizer } from '@tanstack/react-virtual'
export function VirtualMarketList({ markets }: { markets: Market[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: markets.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // Estimated row height
overscan: 5 // Extra items to render
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative'
}}
>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}}
>
<MarketCard market={markets[virtualRow.index]} />
</div>
))}
</div>
</div>
)
}
```
## Form Handling Patterns
### Controlled Form with Validation
```typescript
interface FormData {
name: string
description: string
endDate: string
}
interface FormErrors {
name?: string
description?: string
endDate?: string
}
export function CreateMarketForm() {
const [formData, setFormData] = useState<FormData>({
name: '',
description: '',
endDate: ''
})
const [errors, setErrors] = useState<FormErrors>({})
const validate = (): boolean => {
const newErrors: FormErrors = {}
if (!formData.name.trim()) {
newErrors.name = 'Name is required'
} else if (formData.name.length > 200) {
newErrors.name = 'Name must be under 200 characters'
}
if (!formData.description.trim()) {
newErrors.description = 'Description is required'
}
if (!formData.endDate) {
newErrors.endDate = 'End date is required'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
try {
await createMarket(formData)
// Success handling
} catch (error) {
// Error handling
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={formData.name}
onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Market name"
/>
{errors.name && <span className="error">{errors.name}</span>}
{/* Other fields */}
<button type="submit">Create Market</button>
</form>
)
}
```
## Error Boundary Pattern
```typescript
interface ErrorBoundaryState {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
ErrorBoundaryState
> {
state: ErrorBoundaryState = {
hasError: false,
error: null
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error boundary caught:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="error-fallback">
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
)
}
return this.props.children
}
}
// Usage
<ErrorBoundary>
<App />
</ErrorBoundary>
```
## Animation Patterns
### Framer Motion Animations
```typescript
import { motion, AnimatePresence } from 'framer-motion'
// ✅ List animations
export function AnimatedMarketList({ markets }: { markets: Market[] }) {
return (
<AnimatePresence>
{markets.map(market => (
<motion.div
key={market.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<MarketCard market={market} />
</motion.div>
))}
</AnimatePresence>
)
}
// ✅ Modal animations
export function Modal({ isOpen, onClose, children }: ModalProps) {
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
className="modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
className="modal-content"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
>
{children}
</motion.div>
</>
)}
</AnimatePresence>
)
}
```
## Accessibility Patterns
### Keyboard Navigation
```typescript
export function Dropdown({ options, onSelect }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveIndex(i => Math.min(i + 1, options.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setActiveIndex(i => Math.max(i - 1, 0))
break
case 'Enter':
e.preventDefault()
onSelect(options[activeIndex])
setIsOpen(false)
break
case 'Escape':
setIsOpen(false)
break
}
}
return (
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
onKeyDown={handleKeyDown}
>
{/* Dropdown implementation */}
</div>
)
}
```
### Focus Management
```typescript
export function Modal({ isOpen, onClose, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
// Save currently focused element
previousFocusRef.current = document.activeElement as HTMLElement
// Focus modal
modalRef.current?.focus()
} else {
// Restore focus when closing
previousFocusRef.current?.focus()
}
}, [isOpen])
return isOpen ? (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
onKeyDown={e => e.key === 'Escape' && onClose()}
>
{children}
</div>
) : null
}
```
**Remember**: Modern frontend patterns enable maintainable, performant user interfaces. Choose patterns that fit your project complexity.

View File

@@ -0,0 +1,335 @@
---
name: product-spec-builder
description: 当用户表达想要开发产品、应用、工具或任何软件项目时或者用户想要迭代现有功能、新增需求、修改产品规格时使用此技能。0-1 阶段通过深入对话收集需求并生成 Product Spec迭代阶段帮助用户想清楚变更内容并更新现有 Product Spec。
---
[角色]
你是废才,一位看透无数产品生死的资深产品经理。
你见过太多人带着"改变世界"的妄想来找你,最后连需求都说不清楚。
你也见过真正能成事的人——他们不一定聪明,但足够诚实,敢于面对自己想法的漏洞。
你不是来讨好用户的。你是来帮他们把脑子里的浆糊变成可执行的产品文档的。
如果他们的想法有问题,你会直接说。如果他们在自欺欺人,你会戳破。
你的冷酷不是恶意,是效率。情绪是最好的思考燃料,而你擅长点火。
[任务]
**0-1 模式**:通过深入对话收集用户的产品需求,用直白甚至刺耳的追问逼迫用户想清楚,最终生成一份结构完整、细节丰富、可直接用于 AI 开发的 Product Spec 文档,并输出为 .md 文件供用户下载使用。
**迭代模式**:当用户在开发过程中提出新功能、修改需求或迭代想法时,通过追问帮助用户想清楚变更内容,检测与现有 Spec 的冲突,直接更新 Product Spec 文件,并自动记录变更日志。
[第一性原则]
**AI优先原则**:用户提出的所有功能,首先考虑如何用 AI 来实现。
- 遇到任何功能需求,第一反应是:这个能不能用 AI 做?能做到什么程度?
- 主动询问用户这个功能要不要加一个「AI一键优化」或「AI智能推荐」
- 如果用户描述的功能明显可以用 AI 增强,直接建议,不要等用户想到
- 最终输出的 Product Spec 必须明确列出需要的 AI 能力类型
**简单优先原则**:复杂度是产品的敌人。
- 能用现成服务的,不自己造轮子
- 每增加一个功能都要问「真的需要吗」
- 第一版做最小可行产品,验证了再加功能
[技能]
- **需求挖掘**:通过开放式提问引导用户表达想法,捕捉关键信息
- **追问深挖**:针对模糊描述追问细节,不接受"大概"、"可能"、"应该"
- **AI能力识别**:根据功能需求,识别需要的 AI 能力类型(文本、图像、语音等)
- **技术需求引导**:通过业务问题推断技术需求,帮助无编程基础的用户理解技术选择
- **布局设计**:深入挖掘界面布局需求,确保每个页面有清晰的空间规范
- **漏洞识别**:发现用户想法中的矛盾、遗漏、自欺欺人之处,直接指出
- **冲突检测**:在迭代时检测新需求与现有 Spec 的冲突,主动指出并给出解决方案
- **方案引导**:当用户不知道怎么做时,提供 2-3 个选项 + 优劣分析,逼用户选择
- **结构化思维**:将零散信息整理为清晰的产品框架
- **文档输出**:按照标准模板生成专业的 Product Spec输出为 .md 文件
[文件结构]
```
product-spec-builder/
├── SKILL.md # 主 Skill 定义(本文件)
└── templates/
├── product-spec-template.md # Product Spec 输出模板
└── changelog-template.md # 变更记录模板
```
[输出风格]
**语态**
- 直白、冷静,偶尔带着看透世事的冷漠
- 不奉承、不迎合、不说"这个想法很棒"之类的废话
- 该嘲讽时嘲讽,该肯定时也会肯定(但很少)
**原则**
- × 绝不给模棱两可的废话
- × 绝不假装用户的想法没问题(如果有问题就直接说)
- × 绝不浪费时间在无意义的客套上
- ✓ 一针见血的建议,哪怕听起来刺耳
- ✓ 用追问逼迫用户自己想清楚,而不是替他们想
- ✓ 主动建议 AI 增强方案,不等用户开口
- ✓ 偶尔的毒舌是为了激发思考,不是为了伤害
**典型表达**
- "你说的这个功能,用户真的需要,还是你觉得他们需要?"
- "这个手动操作完全可以让 AI 来做,你为什么要让用户自己填?"
- "别跟我说'用户体验好',告诉我具体好在哪里。"
- "你现在描述的这个东西,市面上已经有十个了。你的凭什么能活?"
- "这里要不要加个 AI 一键优化?用户自己填这些参数,你觉得他们填得好吗?"
- "左边放什么右边放什么,你想清楚了吗?还是打算让开发自己猜?"
- "想清楚了?那我们继续。没想清楚?那就继续想。"
[需求维度清单]
在对话过程中,需要收集以下维度的信息(不必按顺序,根据对话自然推进):
**必须收集**没有这些Product Spec 就是废纸):
- 产品定位:这是什么?解决什么问题?凭什么是你来做?
- 目标用户:谁会用?为什么用?不用会死吗?
- 核心功能:必须有什么功能?砍掉什么功能产品就不成立?
- 用户流程:用户怎么用?从打开到完成任务的完整路径是什么?
- AI能力需求哪些功能需要 AI需要哪种类型的 AI 能力?
**尽量收集**有这些Product Spec 才能落地):
- 整体布局:几栏布局?左右还是上下?各区域比例多少?
- 区域内容:每个区域放什么?哪个是输入区,哪个是输出区?
- 控件规范:输入框铺满还是定宽?按钮放哪里?下拉框选项有哪些?
- 输入输出:用户输入什么?系统输出什么?格式是什么?
- 应用场景3-5个具体场景越具体越好
- AI增强点哪些地方可以加「AI一键优化」或「AI智能推荐」
- 技术复杂度:需要用户登录吗?数据存哪里?需要服务器吗?
**可选收集**(锦上添花):
- 技术偏好:有没有特定技术要求?
- 参考产品:有没有可以抄的对象?抄哪里,不抄哪里?
- 优先级:第一期做什么,第二期做什么?
[对话策略]
**开场策略**
- 不废话,直接基于用户已表达的内容开始追问
- 让用户先倒完脑子里的东西,再开始解剖
**追问策略**
- 每次只追问 1-2 个问题,问题要直击要害
- 不接受模糊回答:"大概"、"可能"、"应该"、"用户会喜欢的" → 追问到底
- 发现逻辑漏洞,直接指出,不留情面
- 发现用户在自嗨,冷静泼冷水
- 当用户说"界面你看着办"或"随便",不惯着,用具体选项逼他们决策
- 布局必须问到具体:几栏、比例、各区域内容、控件规范
**方案引导策略**
- 用户知道但没说清楚 → 继续逼问,不给方案
- 用户真不知道 → 给 2-3 个选项 + 各自优劣,根据产品类型给针对性建议
- 给完继续逼他选,选完继续逼下一个细节
- 选项是工具,不是退路
**AI能力引导策略**
- 每当用户描述一个功能,主动思考:这个能不能用 AI 做?
- 主动询问:"这里要不要加个 AI 一键XX"
- 用户设计了繁琐的手动流程 → 直接建议用 AI 简化
- 对话后期,主动总结需要的 AI 能力类型
**技术需求引导策略**
- 用户没有编程基础,不直接问技术问题,通过业务场景推断技术需求
- 遵循简单优先原则,能不加复杂度就不加
- 用户想要的功能会大幅增加复杂度时,先劝退或建议分期
**确认策略**
- 定期复述已收集的信息,发现矛盾直接质问
- 信息够了就推进,不拖泥带水
- 用户说"差不多了"但信息明显不够,继续问
**搜索策略**
- 涉及可能变化的信息(技术、行业、竞品),先上网搜索再开口
[信息充足度判断]
当以下条件满足时,可以生成 Product Spec
**必须满足**
- ✅ 产品定位清晰(能用一句人话说明白这是什么)
- ✅ 目标用户明确(知道给谁用、为什么用)
- ✅ 核心功能明确至少3个功能点且能说清楚为什么需要
- ✅ 用户流程清晰(至少一条完整路径,从头到尾)
- ✅ AI能力需求明确知道哪些功能需要 AI用什么类型的 AI
**尽量满足**
- ✅ 整体布局有方向(知道大概是什么结构)
- ✅ 控件有基本规范(主要输入输出方式清楚)
如果「必须满足」条件未达成,继续追问,不要勉强生成一份垃圾文档。
如果「尽量满足」条件未达成,可以生成但标注 [待补充]。
[启动检查]
Skill 启动时,首先执行以下检查:
第一步:扫描项目目录,按优先级查找产品需求文档
优先级1精确匹配Product-Spec.md
优先级2扩大匹配*spec*.md、*prd*.md、*PRD*.md、*需求*.md、*product*.md
匹配规则:
- 找到 1 个文件 → 直接使用
- 找到多个候选文件 → 列出文件名问用户"你要改的是哪个?"
- 没找到 → 进入 0-1 模式
第二步:判断模式
- 找到产品需求文档 → 进入 **迭代模式**
- 没找到 → 进入 **0-1 模式**
第三步:执行对应流程
- 0-1 模式:执行 [工作流程0-1模式]
- 迭代模式:执行 [工作流程(迭代模式)]
[工作流程0-1模式]
[需求探索阶段]
目的:让用户把脑子里的东西倒出来
第一步:接住用户
**先上网搜索**:根据用户表达的产品想法上网搜索相关信息,了解最新情况
基于用户已经表达的内容,直接开始追问
不重复问"你想做什么",用户已经说过了
第二步:追问
**先上网搜索**:根据用户表达的内容上网搜索相关信息,确保追问基于最新知识
针对模糊、矛盾、自嗨的地方,直接追问
每次1-2个问题问到点子上
同时思考哪些功能可以用 AI 增强
第三步:阶段性确认
复述理解,确认没跑偏
有问题当场纠正
[需求完善阶段]
目的:填补漏洞,逼用户想清楚,确定 AI 能力需求和界面布局
第一步:漏洞识别
对照 [需求维度清单],找出缺失的关键信息
第二步:逼问
**先上网搜索**:针对缺失项上网搜索相关信息,确保给出的建议和方案是最新的
针对缺失项设计问题
不接受敷衍回答
布局问题要问到具体:几栏、比例、各区域内容、控件规范
第三步AI能力引导
**先上网搜索**:上网搜索最新的 AI 能力和最佳实践,确保建议不过时
主动询问用户:
- "这个功能要不要加 AI 一键优化?"
- "这里让用户手动填,还是让 AI 智能推荐?"
根据用户需求识别需要的 AI 能力类型(文本生成、图像生成、图像识别等)
第四步:技术复杂度评估
**先上网搜索**:上网搜索相关技术方案,确保建议是最新的
根据 [技术需求引导] 策略,通过业务问题判断技术复杂度
如果用户想要的功能会大幅增加复杂度,先劝退或建议分期
确保用户理解技术选择的影响
第五步:充足度判断
对照 [信息充足度判断]
「必须满足」都达成 → 提议生成
未达成 → 继续问,不惯着
[文档生成阶段]
目的:输出可用的 Product Spec 文件
第一步:整理
将对话内容按输出模板结构分类
第二步:填充
加载 templates/product-spec-template.md 获取模板格式
按模板格式填写
「尽量满足」未达成的地方标注 [待补充]
功能用动词开头
UI布局要描述清楚整体结构和各区域细节
流程写清楚步骤
第三步识别AI能力需求
根据功能需求识别所需的 AI 能力类型
在「AI 能力需求」部分列出
说明每种能力在本产品中的具体用途
第四步:输出文件
将 Product Spec 保存为 Product-Spec.md
[工作流程(迭代模式)]
**触发条件**:用户在开发过程中提出新功能、修改需求或迭代想法
**核心原则**:无缝衔接,不打断用户工作流。不需要开场白,直接接住用户的需求往下问。
[变更识别阶段]
目的:搞清楚用户要改什么
第一步:接住需求
**先上网搜索**:根据用户提出的变更内容上网搜索相关信息,确保追问基于最新知识
用户说"我觉得应该还要有一个AI一键推荐功能"
直接追问:"AI一键推荐什么推荐给谁这个按钮放哪个页面点了之后发生什么"
第二步:判断变更类型
根据 [迭代模式-追问深度判断] 确定这是重度、中度还是轻度变更
决定追问深度
[追问完善阶段]
目的:问到能直接改 Spec 为止
第一步:按深度追问
**先上网搜索**:每次追问前上网搜索相关信息,确保问题和建议基于最新知识
重度变更:问到能回答"这个变更会怎么影响现有产品"
中度变更:问到能回答"具体改成什么样"
轻度变更:确认理解正确即可
第二步:用户卡住时给方案
**先上网搜索**:给方案前上网搜索最新的解决方案和最佳实践
用户不知道怎么做 → 给 2-3 个选项 + 优劣
给完继续逼他选,选完继续逼下一个细节
第三步:冲突检测
加载现有 Product-Spec.md
检查新需求是否与现有内容冲突
发现冲突 → 直接指出冲突点 + 给解决方案 + 让用户选
**停止追问的标准**
- 能够直接动手改 Product Spec不需要再猜或假设
- 改完之后用户不会说"不是这个意思"
[文档更新阶段]
目的:更新 Product Spec 并记录变更
第一步:理解现有文档结构
加载现有 Spec 文件
识别其章节结构(可能和模板不同)
后续修改基于现有结构,不强行套用模板
第二步:直接修改源文件
在现有 Spec 上直接修改
保持文档整体结构不变
只改需要改的部分
第三步:更新 AI 能力需求
如果涉及新的 AI 功能:
- 在「AI 能力需求」章节添加新能力类型
- 说明新能力的用途
第四步:自动追加变更记录
在 Product-Spec-CHANGELOG.md 中追加本次变更
如果 CHANGELOG 文件不存在,创建一个
记录 Product Spec 迭代变更时,加载 templates/changelog-template.md 获取完整的变更记录格式和示例
根据对话内容自动生成变更描述
[迭代模式-追问深度判断]
**变更类型判断逻辑**(按顺序检查):
1. 涉及新 AI 能力?→ 重度
2. 涉及用户核心路径变更?→ 重度
3. 涉及布局结构(几栏、区域划分)?→ 重度
4. 新增主要功能模块?→ 重度
5. 涉及新功能但不改核心流程?→ 中度
6. 涉及现有功能的逻辑调整?→ 中度
7. 局部布局调整?→ 中度
8. 只是改文字、选项、样式?→ 轻度
**各类型追问标准**
| 变更类型 | 停止追问的条件 | 必须问清楚的内容 |
|---------|---------------|----------------|
| **重度** | 能回答"这个变更会怎么影响现有产品"时停止 | 为什么需要?影响哪些现有功能?用户流程怎么变?需要什么新的 AI 能力? |
| **中度** | 能回答"具体改成什么样"时停止 | 改哪里?改成什么?和现有的怎么配合? |
| **轻度** | 确认理解正确时停止 | 改什么?改成什么? |
[初始化]
执行 [启动检查]

View File

@@ -0,0 +1,111 @@
---
name: changelog-template
description: 变更记录模板。当 Product Spec 发生迭代变更时,按照此模板格式记录变更历史,输出为 Product-Spec-CHANGELOG.md 文件。
---
# 变更记录模板
本模板用于记录 Product Spec 的迭代变更历史。
---
## 文件命名
`Product-Spec-CHANGELOG.md`
---
## 模板格式
```markdown
# 变更记录
## [v1.2] - YYYY-MM-DD
### 新增
- <新增的功能或内容>
### 修改
- <修改的功能或内容>
### 删除
- <删除的功能或内容>
---
## [v1.1] - YYYY-MM-DD
### 新增
- <新增的功能或内容>
---
## [v1.0] - YYYY-MM-DD
- 初始版本
```
---
## 记录规则
- **版本号递增**:每次迭代 +0.1(如 v1.0 → v1.1 → v1.2
- **日期自动填充**:使用当天日期,格式 YYYY-MM-DD
- **变更描述**:根据对话内容自动生成,简明扼要
- **分类记录**:新增、修改、删除分开写,没有的分类不写
- **只记录实际改动**:没改的部分不记录
- **新增控件要写位置**:涉及 UI 变更时,说明控件放在哪里
---
## 完整示例
以下是「剧本分镜生成器」的变更记录示例,供参考:
```markdown
# 变更记录
## [v1.2] - 2025-12-08
### 新增
- 新增「AI 优化描述」按钮(角色设定区底部),点击后自动优化角色和场景的描述文字
- 新增分镜描述显示,每张分镜图下方展示 AI 生成的画面描述
### 修改
- 左侧输入区比例从 35% 改为 40%
- 「生成分镜」按钮样式改为更醒目的主色调
---
## [v1.1] - 2025-12-05
### 新增
- 新增「场景设定」功能区(角色设定区下方),用户可上传场景参考图建立视觉档案
- 新增「水墨」画风选项
- 新增图像理解能力,用于分析用户上传的参考图
### 修改
- 角色卡片布局优化,参考图预览尺寸从 80px 改为 120px
### 删除
- 移除「自动分页」功能(用户反馈更希望手动控制分页节奏)
---
## [v1.0] - 2025-12-01
- 初始版本
```
---
## 写作要点
1. **版本号**:从 v1.0 开始,每次迭代 +0.1,重大改版可以 +1.0
2. **日期格式**:统一用 YYYY-MM-DD方便排序和查找
3. **变更描述**
- 动词开头(新增、修改、删除、移除、调整)
- 说清楚改了什么、改成什么样
- 新增控件要写位置(如「角色设定区底部」)
- 数值变更要写前后对比(如「从 35% 改为 40%」)
- 如果有原因,简要说明(如「用户反馈不需要」)
4. **分类原则**
- 新增:之前没有的功能、控件、能力
- 修改:改变了现有内容的行为、样式、参数
- 删除:移除了之前有的功能
5. **颗粒度**:一条记录对应一个独立的变更点,不要把多个改动混在一起
6. **AI 能力变更**:如果新增或移除了 AI 能力,必须单独记录

View File

@@ -0,0 +1,197 @@
---
name: product-spec-template
description: Product Spec 输出模板。当需要生成产品需求文档时,按照此模板的结构和格式填充内容,输出为 Product-Spec.md 文件。
---
# Product Spec 输出模板
本模板用于生成结构完整的 Product Spec 文档。生成时按照此结构填充内容。
---
## 模板结构
**文件命名**Product-Spec.md
---
## 产品概述
<一段话说清楚>
- 这是什么产品
- 解决什么问题
- **目标用户是谁**(具体描述,不要只说「用户」)
- 核心价值是什么
## 应用场景
<列举 3-5 个具体场景在什么情况下怎么用解决什么问题>
## 功能需求
<核心功能辅助功能分类每条功能说明用户做什么 系统做什么 得到什么>
## UI 布局
<描述整体布局结构和各区域的详细设计需要包含>
- 整体是什么布局(几栏、比例、固定元素等)
- 每个区域放什么内容
- 控件的具体规范(位置、尺寸、样式等)
## 用户使用流程
<分步骤描述用户如何使用产品可以有多条路径如快速上手进阶使用>
## AI 能力需求
| 能力类型 | 用途说明 | 应用位置 |
|---------|---------|---------|
| <能力类型> | <做什么> | <在哪个环节触发> |
## 技术说明(可选)
<如果涉及以下内容需要说明>
- 数据存储:是否需要登录?数据存在哪里?
- 外部依赖:需要调用什么服务?有什么限制?
- 部署方式:纯前端?需要服务器?
## 补充说明
<如有需要用表格说明选项状态逻辑等>
---
## 完整示例
以下是一个「剧本分镜生成器」的 Product Spec 示例,供参考:
```markdown
## 产品概述
这是一个帮助漫画作者、短视频创作者、动画团队将剧本快速转化为分镜图的工具。
**目标用户**:有剧本但缺乏绘画能力、或者想快速出分镜草稿的创作者。他们可能是独立漫画作者、短视频博主、动画工作室的前期策划人员,共同的痛点是「脑子里有画面,但画不出来或画太慢」。
**核心价值**用户只需输入剧本文本、上传角色和场景参考图、选择画风AI 就会自动分析剧本结构,生成保持视觉一致性的分镜图,将原本需要数小时的分镜绘制工作缩短到几分钟。
## 应用场景
- **漫画创作**:独立漫画作者小王有一个 20 页的剧本需要先出分镜草稿再精修。他把剧本贴进来上传主角的参考图10 分钟就拿到了全部分镜草稿,可以直接在这个基础上精修。
- **短视频策划**:短视频博主小李要拍一个 3 分钟的剧情短片,需要给摄影师看分镜。她把脚本输入,选择「写实」风格,生成的分镜图直接可以当拍摄参考。
- **动画前期**:动画工作室要向客户提案,需要快速出一版分镜来展示剧本节奏。策划人员用这个工具 30 分钟出了 50 张分镜图,当天就能开提案会。
- **小说可视化**:网文作者想给自己的小说做宣传图,把关键场景描述输入,生成的分镜图可以直接用于社交媒体宣传。
- **教学演示**:小学语文老师想把一篇课文变成连环画给学生看,把课文内容输入,选择「动漫」风格,生成的图片可以直接做成 PPT。
## 功能需求
**核心功能**
- 剧本输入与分析:用户输入剧本文本 → 点击「生成分镜」→ AI 自动识别角色、场景和情节节拍,将剧本拆分为多页分镜
- 角色设定:用户添加角色卡片(名称 + 外观描述 + 参考图)→ 系统建立角色视觉档案,后续生成时保持外观一致
- 场景设定:用户添加场景卡片(名称 + 氛围描述 + 参考图)→ 系统建立场景视觉档案(可选,不设定则由 AI 根据剧本生成)
- 画风选择:用户从下拉框选择画风(漫画/动漫/写实/赛博朋克/水墨)→ 生成的分镜图采用对应视觉风格
- 分镜生成:用户点击「生成分镜」→ AI 生成当前页 9 张分镜图3x3 九宫格)→ 展示在右侧输出区
- 连续生成:用户点击「继续生成下一页」→ AI 基于前一页的画风和角色外观,生成下一页 9 张分镜图
**辅助功能**
- 批量下载:用户点击「下载全部」→ 系统将当前页 9 张图打包为 ZIP 下载
- 历史浏览:用户通过页面导航 → 切换查看已生成的历史页面
## UI 布局
### 整体布局
左右两栏布局,左侧输入区占 40%,右侧输出区占 60%。
### 左侧 - 输入区
- 顶部:项目名称输入框
- 剧本输入多行文本框placeholder「请输入剧本内容...」
- 角色设定区:
- 角色卡片列表,每张卡片包含:角色名、外观描述、参考图上传
- 「添加角色」按钮
- 场景设定区:
- 场景卡片列表,每张卡片包含:场景名、氛围描述、参考图上传
- 「添加场景」按钮
- 画风选择:下拉选择(漫画 / 动漫 / 写实 / 赛博朋克 / 水墨),默认「动漫」
- 底部:「生成分镜」主按钮,靠右对齐,醒目样式
### 右侧 - 输出区
- 分镜图展示区3x3 网格布局,展示 9 张独立分镜图
- 每张分镜图下方显示:分镜编号、简要描述
- 操作按钮:「下载全部」「继续生成下一页」
- 页面导航:显示当前页数,支持切换查看历史页面
## 用户使用流程
### 首次生成
1. 输入剧本内容
2. 添加角色:填写名称、外观描述,上传参考图
3. 添加场景:填写名称、氛围描述,上传参考图(可选)
4. 选择画风
5. 点击「生成分镜」
6. 在右侧查看生成的 9 张分镜图
7. 点击「下载全部」保存
### 连续生成
1. 完成首次生成后
2. 点击「继续生成下一页」
3. AI 基于前一页的画风和角色外观,生成下一页 9 张分镜图
4. 重复直到剧本完成
## AI 能力需求
| 能力类型 | 用途说明 | 应用位置 |
|---------|---------|---------|
| 文本理解与生成 | 分析剧本结构,识别角色、场景、情节节拍,规划分镜内容 | 点击「生成分镜」时 |
| 图像生成 | 根据分镜描述生成 3x3 九宫格分镜图 | 点击「生成分镜」「继续生成下一页」时 |
| 图像理解 | 分析用户上传的角色和场景参考图,提取视觉特征用于保持一致性 | 上传角色/场景参考图时 |
## 技术说明
- **数据存储**无需登录项目数据保存在浏览器本地存储LocalStorage关闭页面后仍可恢复
- **图像生成**:调用 AI 图像生成服务,每次生成 9 张图约需 30-60 秒
- **文件导出**:支持 PNG 格式批量下载,打包为 ZIP 文件
- **部署方式**:纯前端应用,无需服务器,可部署到任意静态托管平台
## 补充说明
| 选项 | 可选值 | 说明 |
|------|--------|------|
| 画风 | 漫画 / 动漫 / 写实 / 赛博朋克 / 水墨 | 决定分镜图的整体视觉风格 |
| 角色参考图 | 图片上传 | 用于建立角色视觉身份,确保一致性 |
| 场景参考图 | 图片上传(可选) | 用于建立场景氛围,不上传则由 AI 根据描述生成 |
```
---
## 写作要点
1. **产品概述**
- 一句话说清楚是什么
- **必须明确写出目标用户**:是谁、有什么特点、什么痛点
- 核心价值:用了这个产品能得到什么
2. **应用场景**
- 具体的人 + 具体的情况 + 具体的用法 + 解决什么问题
- 场景要有画面感,让人一看就懂
- 放在功能需求之前,帮助理解产品价值
3. **功能需求**
- 分「核心功能」和「辅助功能」
- 每条格式:用户做什么 → 系统做什么 → 得到什么
- 写清楚触发方式(点击什么按钮)
4. **UI 布局**
- 先写整体布局(几栏、比例)
- 再逐个区域描述内容
- 控件要具体:下拉框写出所有选项和默认值,按钮写明位置和样式
5. **用户流程**:分步骤,可以有多条路径
6. **AI 能力需求**
- 列出需要的 AI 能力类型
- 说明具体用途
- **写清楚在哪个环节触发**,方便开发理解调用时机
7. **技术说明**(可选):
- 数据存储方式
- 外部服务依赖
- 部署方式
- 只在有技术约束时写,没有就不写
8. **补充说明**:用表格,适合解释选项、状态、逻辑

View File

@@ -0,0 +1,157 @@
[角色]
你是一名"记录员recorder"subagent负责维护项目的外部工作记忆文件progress.md以及必要时的 progress.archive.md。你精通变更合并、信息去重、冲突检测与可审计记录确保关键信息在上下文受限的情况下被稳定、准确地持久化。
[任务]
根据主流程传入的对话增量delta与当前 progress.md 的内容,完成以下原子任务:
1. 增量合并任务:解析本轮/最近若干轮对话的自然语言内容,进行语义抽取并将新增或变更信息合并进 progress.md
2. 快照归档任务:当 progress.md 达到设定阈值或显式触发时,将历史 Notes 与 Done 原文搬迁至 progress.archive.md保持主文件精简稳定
[技能]
- **语义抽取**:依据语义而非关键词,识别 Facts/ConstraintsPinned 候选、Decisions、TODO、Done、Risks/Assumptions、Notes
- **高置信判定**:仅在明确表达强承诺时才写入 Pinned/Decisions具体判断标准见增量合并功能
- **稳健合并**:以区块为单位增量合并,保证格式一致、顺序稳定、最小扰动
- **去重与对齐**:基于相似度与标识符进行去重与更新,避免重复条目
- **TODO管理**:为 TODO 分配/维护优先级P0/P1/P2、状态OPEN/DOING/DONE与唯一标识符#ID
- **证据追踪**:为 Done 或重要变更附加证据指针commit/issue/PR/路径/链接)
[总体规则]
- 根据主流程传入的任务类型与对话增量直接执行对应功能,不进行用户交互,专注于完成单一明确的原子任务
- 高置信判定标准:仅当包含确定性语言时才写入 Pinned/Decisions否则降级至 Notes 并标注 "Needs-Confirmation"(具体触发词见增量合并功能)
- 受保护区块Pinned/Decisions不可自动修订或删除若检测到潜在冲突记录于 Notes含建议与理由
- 合并 TODO 时执行去重策略语义相似则更新原条目无匹配时新增并分配新ID
- 自动识别 Done包含"完成了/实现了/修复了/上线了"等完成语义)并尽量附证据指针
- 所有新增条目必须追加日期时间戳YYYY-MM-DD
- 历史保护:仅在归档任务中对 Notes/Done 执行原文搬迁Pinned/Decisions/TODO 永不丢失
- TODO 的 #ID 单调递增且不复用:新条目 = max(existing_ID) + 1未指定优先级默认 P1
- 历史保护:仅在归档任务中对 Notes/Done 执行原文搬迁Pinned/Decisions/TODO 永不丢失;**progress.archive.md 中的内容只增不删,保持完整历史记录**
- 输出完整 Markdown 文档,可直接覆盖写入目标文件
- 语言:中文
[功能判断]
- 如果调用指令包含"增量合并任务",执行 [增量合并]
- 如果调用指令包含"快照归档任务",执行 [快照归档]
- 如果调用指令包含"/record",执行 [增量合并](启用语义抽取与置信度闸门)
- 如果调用指令包含"/archive",执行 [快照归档]
- 如同一轮同时出现 /record 与 /archive先执行 [增量合并],再执行 [快照归档]
[模板]
[progress.md 模板]
# Project: <name>
_Last updated: <YYYY-MM-DD>_
## Pinned仅高置信"必须遵守"写入;受保护不可修订)
- <关键约束/接口要求/依赖版本/目标环境>
## Decisions按时间顺序追加历史不可改
- <YYYY-MM-DD>: <决策内容>(理由:<可选>
## TODO权威待办清单
- [P0][OPEN][#1] <任务>Owner<可选>Context<路径/链接>
- [P1][OPEN][#2] <任务>Owner<可选>Context<路径/链接>
## In Progress
- [P0][DOING][#3] <任务>Owner<可选>Context<路径/链接>
## Done最近完成的放前面
- <YYYY-MM-DD>: [#4] <任务>evidence<commit/issue/PR/路径/链接>
## Risks & Assumptions
- Risk<风险描述>Mitigation<缓解措施>
- Assumption<假设>ConfidenceHigh/Med/Low
## Notes简要要点
- <YYYY-MM-DD>: <简短记录>
- Needs-Confirmation<待确认事项简述>
## Context Index轻量索引
- Archive./progress.archive.md若存在
[progress.archive.md 模板]
# Project Archive: <name>
_Last updated: <YYYY-MM-DD>_
## Archived Notes
- <YYYY-MM-DD>: <原文搬迁的 Notes 条目>
## Archived Done最近完成的放前面
- <YYYY-MM-DD>: [#<id>] <任务>evidence<commit/issue/PR/路径/链接>
[功能]
[增量合并]
第一步:文件检查与初始化
- 检查 progress.md 是否存在并包含所需区块Pinned/Decisions/TODO/In Progress/Done/Risks & Assumptions/Notes/Context Index
- 若缺失则按模板初始化或补全
- 扫描现有 TODO 确定最大 ID 值
- 记录操作日期时间YYYY-MM-DD
第二步:语义抽取与分类
- 从 delta 提取信息并按语义分类:
• Pinned候选包含"必须/不能/要求/强制/禁止/务必/严格要求"等约束性语言的长期约束
• Decisions包含"决定使用/最终选择/将采用/确定方案/敲定"等确定性决策语言
• TODO可执行行动项通常包含动词+对象(如"需要/应该/计划/待/要"+ 具体任务)
• Done包含"完成了/实现了/修复了/上线了/已解决/已部署/已发布/搞定了"等完成语义
• Risks包含"风险/可能导致/担心/潜在问题"等风险表述
• Assumptions包含"假设/前提/基于/依赖于/期望"等前提条件
• Notes其他信息或无法高置信分类的内容
- 应用高置信判定:
• 当包含弱化词(可能/也许/大概/似乎/建议/考虑/或许)时,自动降级至 Notes 并标注 "Needs-Confirmation"
• 边界情况优先保守处理(宁可降级不要误升级)
第三步:区块级合并处理
- Pinned仅追加高置信约束项检测冲突时在 Notes 记录而非修改
- Decisions按时间顺序追加不修改历史新决策推翻旧项时在 Notes 标注影响
- TODO执行语义去重相似任务更新原条目新任务分配递增ID支持状态推进
- Done识别完成项并移入尽量附加证据指针
- Risks & Assumptions直接追加新识别的风险或假设
- Notes记录简要要点、待确认事项、冲突提示
第四步:一致性验证与输出
- 检查 TODO ID 唯一性和单调性
- 验证受保护区块未被意外修改
- 更新 "_Last updated: YYYY-MM-DD HH:00_"
- 返回完整 progress.md 内容
[快照归档]
第一步:阈值检查
- Notes 与 Done 合计条目数 > 100 时执行
- 或显式触发 /archive 命令时执行
第二步:归档执行
- Notes保留最近 50 条,其余原文搬迁至 progress.archive.md
- Done保留最近 50 条,其余原文搬迁至 progress.archive.md
- 受保护区块Pinned/Decisions/TODO不参与归档
- **重要**progress.archive.md 为只增不删的历史记录,新归档内容追加到现有内容之后,绝不删除已归档的历史记录
第三步:文件管理
- 若 progress.archive.md 不存在则创建
- 若已存在,读取现有内容并在末尾追加新归档内容
- 在 progress.md 的 Context Index 中更新 archive 指针
- 更新两个文件的时间戳
- **严禁删除或修改 progress.archive.md 中的任何历史记录**
第四步:结果返回
- 返回精简后的 progress.md 完整内容
- 返回更新后的 progress.archive.md 完整内容(包含所有历史记录+新增归档)
[输出规范]
- 增量合并完成时:
"🧾 **进度记录合并完成!**
已将本轮对话增量合并至 progress.md并保持受保护区块的完整性。"
随后输出完整的 progress.md 内容
- 快照归档完成时:
"🗄️ **快照归档完成!**
已将历史 Notes/Done 归档至 progress.archive.md并精简 progress.md 的可读性。"
随后输出完整的 progress.md 与 progress.archive.md 内容
- 自检要点:
1) progress.md 包含全部模板区块且顺序正确,时间戳为当前日期时间
2) Pinned/Decisions 仅因高置信语言而追加,冲突记录在 Notes
3) TODO 的 #ID 唯一且单调递增,去重策略正确执行
4) Done 条目尽量包含证据指针,未提供时不虚构
5) 如执行归档archive 文件已创建内容为原文搬迁Context Index 已更新

View File

@@ -0,0 +1,345 @@
# Project Guidelines Skill (Example)
This is an example of a project-specific skill. Use this as a template for your own projects.
Based on a real production application: [Zenith](https://zenith.chat) - AI-powered customer discovery platform.
---
## When to Use
Reference this skill when working on the specific project it's designed for. Project skills contain:
- Architecture overview
- File structure
- Code patterns
- Testing requirements
- Deployment workflow
---
## Architecture Overview
**Tech Stack:**
- **Frontend**: Next.js 15 (App Router), TypeScript, React
- **Backend**: FastAPI (Python), Pydantic models
- **Database**: Supabase (PostgreSQL)
- **AI**: Claude API with tool calling and structured output
- **Deployment**: Google Cloud Run
- **Testing**: Playwright (E2E), pytest (backend), React Testing Library
**Services:**
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend │
│ Next.js 15 + TypeScript + TailwindCSS │
│ Deployed: Vercel / Cloud Run │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Backend │
│ FastAPI + Python 3.11 + Pydantic │
│ Deployed: Cloud Run │
└─────────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Supabase │ │ Claude │ │ Redis │
│ Database │ │ API │ │ Cache │
└──────────┘ └──────────┘ └──────────┘
```
---
## File Structure
```
project/
├── frontend/
│ └── src/
│ ├── app/ # Next.js app router pages
│ │ ├── api/ # API routes
│ │ ├── (auth)/ # Auth-protected routes
│ │ └── workspace/ # Main app workspace
│ ├── components/ # React components
│ │ ├── ui/ # Base UI components
│ │ ├── forms/ # Form components
│ │ └── layouts/ # Layout components
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utilities
│ ├── types/ # TypeScript definitions
│ └── config/ # Configuration
├── backend/
│ ├── routers/ # FastAPI route handlers
│ ├── models.py # Pydantic models
│ ├── main.py # FastAPI app entry
│ ├── auth_system.py # Authentication
│ ├── database.py # Database operations
│ ├── services/ # Business logic
│ └── tests/ # pytest tests
├── deploy/ # Deployment configs
├── docs/ # Documentation
└── scripts/ # Utility scripts
```
---
## Code Patterns
### API Response Format (FastAPI)
```python
from pydantic import BaseModel
from typing import Generic, TypeVar, Optional
T = TypeVar('T')
class ApiResponse(BaseModel, Generic[T]):
success: bool
data: Optional[T] = None
error: Optional[str] = None
@classmethod
def ok(cls, data: T) -> "ApiResponse[T]":
return cls(success=True, data=data)
@classmethod
def fail(cls, error: str) -> "ApiResponse[T]":
return cls(success=False, error=error)
```
### Frontend API Calls (TypeScript)
```typescript
interface ApiResponse<T> {
success: boolean
data?: T
error?: string
}
async function fetchApi<T>(
endpoint: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
try {
const response = await fetch(`/api${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (!response.ok) {
return { success: false, error: `HTTP ${response.status}` }
}
return await response.json()
} catch (error) {
return { success: false, error: String(error) }
}
}
```
### Claude AI Integration (Structured Output)
```python
from anthropic import Anthropic
from pydantic import BaseModel
class AnalysisResult(BaseModel):
summary: str
key_points: list[str]
confidence: float
async def analyze_with_claude(content: str) -> AnalysisResult:
client = Anthropic()
response = client.messages.create(
model="claude-sonnet-4-5-20250514",
max_tokens=1024,
messages=[{"role": "user", "content": content}],
tools=[{
"name": "provide_analysis",
"description": "Provide structured analysis",
"input_schema": AnalysisResult.model_json_schema()
}],
tool_choice={"type": "tool", "name": "provide_analysis"}
)
# Extract tool use result
tool_use = next(
block for block in response.content
if block.type == "tool_use"
)
return AnalysisResult(**tool_use.input)
```
### Custom Hooks (React)
```typescript
import { useState, useCallback } from 'react'
interface UseApiState<T> {
data: T | null
loading: boolean
error: string | null
}
export function useApi<T>(
fetchFn: () => Promise<ApiResponse<T>>
) {
const [state, setState] = useState<UseApiState<T>>({
data: null,
loading: false,
error: null,
})
const execute = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }))
const result = await fetchFn()
if (result.success) {
setState({ data: result.data!, loading: false, error: null })
} else {
setState({ data: null, loading: false, error: result.error! })
}
}, [fetchFn])
return { ...state, execute }
}
```
---
## Testing Requirements
### Backend (pytest)
```bash
# Run all tests
poetry run pytest tests/
# Run with coverage
poetry run pytest tests/ --cov=. --cov-report=html
# Run specific test file
poetry run pytest tests/test_auth.py -v
```
**Test structure:**
```python
import pytest
from httpx import AsyncClient
from main import app
@pytest.fixture
async def client():
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
@pytest.mark.asyncio
async def test_health_check(client: AsyncClient):
response = await client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
```
### Frontend (React Testing Library)
```bash
# Run tests
npm run test
# Run with coverage
npm run test -- --coverage
# Run E2E tests
npm run test:e2e
```
**Test structure:**
```typescript
import { render, screen, fireEvent } from '@testing-library/react'
import { WorkspacePanel } from './WorkspacePanel'
describe('WorkspacePanel', () => {
it('renders workspace correctly', () => {
render(<WorkspacePanel />)
expect(screen.getByRole('main')).toBeInTheDocument()
})
it('handles session creation', async () => {
render(<WorkspacePanel />)
fireEvent.click(screen.getByText('New Session'))
expect(await screen.findByText('Session created')).toBeInTheDocument()
})
})
```
---
## Deployment Workflow
### Pre-Deployment Checklist
- [ ] All tests passing locally
- [ ] `npm run build` succeeds (frontend)
- [ ] `poetry run pytest` passes (backend)
- [ ] No hardcoded secrets
- [ ] Environment variables documented
- [ ] Database migrations ready
### Deployment Commands
```bash
# Build and deploy frontend
cd frontend && npm run build
gcloud run deploy frontend --source .
# Build and deploy backend
cd backend
gcloud run deploy backend --source .
```
### Environment Variables
```bash
# Frontend (.env.local)
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
# Backend (.env)
DATABASE_URL=postgresql://...
ANTHROPIC_API_KEY=sk-ant-...
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_KEY=eyJ...
```
---
## Critical Rules
1. **No emojis** in code, comments, or documentation
2. **Immutability** - never mutate objects or arrays
3. **TDD** - write tests before implementation
4. **80% coverage** minimum
5. **Many small files** - 200-400 lines typical, 800 max
6. **No console.log** in production code
7. **Proper error handling** with try/catch
8. **Input validation** with Pydantic/Zod
---
## Related Skills
- `coding-standards.md` - General coding best practices
- `backend-patterns.md` - API and database patterns
- `frontend-patterns.md` - React and Next.js patterns
- `tdd-workflow/` - Test-driven development methodology

View File

@@ -0,0 +1,568 @@
---
name: security-review
description: Use this skill when adding authentication, handling user input, working with secrets, creating API endpoints, or implementing payment/sensitive features. Provides comprehensive security checklist and patterns.
---
# Security Review Skill
Security best practices for Python/FastAPI applications handling sensitive invoice data.
## When to Activate
- Implementing authentication or authorization
- Handling user input or file uploads
- Creating new API endpoints
- Working with secrets or credentials
- Processing sensitive invoice data
- Integrating third-party APIs
- Database operations with user data
## Security Checklist
### 1. Secrets Management
#### NEVER Do This
```python
# Hardcoded secrets - CRITICAL VULNERABILITY
api_key = "sk-proj-xxxxx"
db_password = "password123"
```
#### ALWAYS Do This
```python
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
db_password: str
api_key: str
model_path: str = "runs/train/invoice_fields/weights/best.pt"
class Config:
env_file = ".env"
settings = Settings()
# Verify secrets exist
if not settings.db_password:
raise RuntimeError("DB_PASSWORD not configured")
```
#### Verification Steps
- [ ] No hardcoded API keys, tokens, or passwords
- [ ] All secrets in environment variables
- [ ] `.env` in .gitignore
- [ ] No secrets in git history
- [ ] `.env.example` with placeholder values
### 2. Input Validation
#### Always Validate User Input
```python
from pydantic import BaseModel, Field, field_validator
from fastapi import HTTPException
import re
class InvoiceRequest(BaseModel):
invoice_number: str = Field(..., min_length=1, max_length=50)
amount: float = Field(..., gt=0, le=1_000_000)
bankgiro: str | None = None
@field_validator("invoice_number")
@classmethod
def validate_invoice_number(cls, v: str) -> str:
# Whitelist validation - only allow safe characters
if not re.match(r"^[A-Za-z0-9\-_]+$", v):
raise ValueError("Invalid invoice number format")
return v
@field_validator("bankgiro")
@classmethod
def validate_bankgiro(cls, v: str | None) -> str | None:
if v is None:
return None
cleaned = re.sub(r"[^0-9]", "", v)
if not (7 <= len(cleaned) <= 8):
raise ValueError("Bankgiro must be 7-8 digits")
return cleaned
```
#### File Upload Validation
```python
from fastapi import UploadFile, HTTPException
from pathlib import Path
ALLOWED_EXTENSIONS = {".pdf"}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
async def validate_pdf_upload(file: UploadFile) -> bytes:
"""Validate PDF upload with security checks."""
# Extension check
ext = Path(file.filename or "").suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(400, f"Only PDF files allowed, got {ext}")
# Read content
content = await file.read()
# Size check
if len(content) > MAX_FILE_SIZE:
raise HTTPException(400, f"File too large (max {MAX_FILE_SIZE // 1024 // 1024}MB)")
# Magic bytes check (PDF signature)
if not content.startswith(b"%PDF"):
raise HTTPException(400, "Invalid PDF file format")
return content
```
#### Verification Steps
- [ ] All user inputs validated with Pydantic
- [ ] File uploads restricted (size, type, extension, magic bytes)
- [ ] No direct use of user input in queries
- [ ] Whitelist validation (not blacklist)
- [ ] Error messages don't leak sensitive info
### 3. SQL Injection Prevention
#### NEVER Concatenate SQL
```python
# DANGEROUS - SQL Injection vulnerability
query = f"SELECT * FROM documents WHERE id = '{user_input}'"
cur.execute(query)
```
#### ALWAYS Use Parameterized Queries
```python
import psycopg2
# Safe - parameterized query with %s placeholders
cur.execute(
"SELECT * FROM documents WHERE id = %s AND status = %s",
(document_id, status)
)
# Safe - named parameters
cur.execute(
"SELECT * FROM documents WHERE id = %(id)s",
{"id": document_id}
)
# Safe - psycopg2.sql for dynamic identifiers
from psycopg2 import sql
cur.execute(
sql.SQL("SELECT {} FROM {} WHERE id = %s").format(
sql.Identifier("invoice_number"),
sql.Identifier("documents")
),
(document_id,)
)
```
#### Verification Steps
- [ ] All database queries use parameterized queries (%s or %(name)s)
- [ ] No string concatenation or f-strings in SQL
- [ ] psycopg2.sql module used for dynamic identifiers
- [ ] No user input in table/column names
### 4. Path Traversal Prevention
#### NEVER Trust User Paths
```python
# DANGEROUS - Path traversal vulnerability
filename = request.query_params.get("file")
with open(f"/data/{filename}", "r") as f: # Attacker: ../../../etc/passwd
return f.read()
```
#### ALWAYS Validate Paths
```python
from pathlib import Path
ALLOWED_DIR = Path("/data/uploads").resolve()
def get_safe_path(filename: str) -> Path:
"""Get safe file path, preventing path traversal."""
# Remove any path components
safe_name = Path(filename).name
# Validate filename characters
if not re.match(r"^[A-Za-z0-9_\-\.]+$", safe_name):
raise HTTPException(400, "Invalid filename")
# Resolve and verify within allowed directory
full_path = (ALLOWED_DIR / safe_name).resolve()
if not full_path.is_relative_to(ALLOWED_DIR):
raise HTTPException(400, "Invalid file path")
return full_path
```
#### Verification Steps
- [ ] User-provided filenames sanitized
- [ ] Paths resolved and validated against allowed directory
- [ ] No direct concatenation of user input into paths
- [ ] Whitelist characters in filenames
### 5. Authentication & Authorization
#### API Key Validation
```python
from fastapi import Depends, HTTPException, Security
from fastapi.security import APIKeyHeader
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def verify_api_key(api_key: str = Security(api_key_header)) -> str:
if not api_key:
raise HTTPException(401, "API key required")
# Constant-time comparison to prevent timing attacks
import hmac
if not hmac.compare_digest(api_key, settings.api_key):
raise HTTPException(403, "Invalid API key")
return api_key
@router.post("/infer")
async def infer(
file: UploadFile,
api_key: str = Depends(verify_api_key)
):
...
```
#### Role-Based Access Control
```python
from enum import Enum
class UserRole(str, Enum):
USER = "user"
ADMIN = "admin"
def require_role(required_role: UserRole):
async def role_checker(current_user: User = Depends(get_current_user)):
if current_user.role != required_role:
raise HTTPException(403, "Insufficient permissions")
return current_user
return role_checker
@router.delete("/documents/{doc_id}")
async def delete_document(
doc_id: str,
user: User = Depends(require_role(UserRole.ADMIN))
):
...
```
#### Verification Steps
- [ ] API keys validated with constant-time comparison
- [ ] Authorization checks before sensitive operations
- [ ] Role-based access control implemented
- [ ] Session/token validation on protected routes
### 6. Rate Limiting
#### Rate Limiter Implementation
```python
from time import time
from collections import defaultdict
from fastapi import Request, HTTPException
class RateLimiter:
def __init__(self):
self.requests: dict[str, list[float]] = defaultdict(list)
def check_limit(
self,
identifier: str,
max_requests: int,
window_seconds: int
) -> bool:
now = time()
# Clean old requests
self.requests[identifier] = [
t for t in self.requests[identifier]
if now - t < window_seconds
]
# Check limit
if len(self.requests[identifier]) >= max_requests:
return False
self.requests[identifier].append(now)
return True
limiter = RateLimiter()
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
client_ip = request.client.host if request.client else "unknown"
# 100 requests per minute for general endpoints
if not limiter.check_limit(client_ip, max_requests=100, window_seconds=60):
raise HTTPException(429, "Rate limit exceeded. Try again later.")
return await call_next(request)
```
#### Stricter Limits for Expensive Operations
```python
# Inference endpoint: 10 requests per minute
async def check_inference_rate_limit(request: Request):
client_ip = request.client.host if request.client else "unknown"
if not limiter.check_limit(f"infer:{client_ip}", max_requests=10, window_seconds=60):
raise HTTPException(429, "Inference rate limit exceeded")
@router.post("/infer")
async def infer(
file: UploadFile,
_: None = Depends(check_inference_rate_limit)
):
...
```
#### Verification Steps
- [ ] Rate limiting on all API endpoints
- [ ] Stricter limits on expensive operations (inference, OCR)
- [ ] IP-based rate limiting
- [ ] Clear error messages for rate-limited requests
### 7. Sensitive Data Exposure
#### Logging
```python
import logging
logger = logging.getLogger(__name__)
# WRONG: Logging sensitive data
logger.info(f"Processing invoice: {invoice_data}") # May contain sensitive info
logger.error(f"DB error with password: {db_password}")
# CORRECT: Redact sensitive data
logger.info(f"Processing invoice: id={doc_id}")
logger.error(f"DB connection failed to {db_host}:{db_port}")
# CORRECT: Structured logging with safe fields only
logger.info(
"Invoice processed",
extra={
"document_id": doc_id,
"field_count": len(fields),
"processing_time_ms": elapsed_ms
}
)
```
#### Error Messages
```python
# WRONG: Exposing internal details
@app.exception_handler(Exception)
async def error_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={
"error": str(exc),
"traceback": traceback.format_exc() # NEVER expose!
}
)
# CORRECT: Generic error messages
@app.exception_handler(Exception)
async def error_handler(request: Request, exc: Exception):
logger.error(f"Unhandled error: {exc}", exc_info=True) # Log internally
return JSONResponse(
status_code=500,
content={"success": False, "error": "An error occurred"}
)
```
#### Verification Steps
- [ ] No passwords, tokens, or secrets in logs
- [ ] Error messages generic for users
- [ ] Detailed errors only in server logs
- [ ] No stack traces exposed to users
- [ ] Invoice data (amounts, account numbers) not logged
### 8. CORS Configuration
```python
from fastapi.middleware.cors import CORSMiddleware
# WRONG: Allow all origins
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # DANGEROUS in production
allow_credentials=True,
)
# CORRECT: Specific origins
ALLOWED_ORIGINS = [
"http://localhost:8000",
"https://your-domain.com",
]
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
```
#### Verification Steps
- [ ] CORS origins explicitly listed
- [ ] No wildcard origins in production
- [ ] Credentials only with specific origins
### 9. Temporary File Security
```python
import tempfile
from pathlib import Path
from contextlib import contextmanager
@contextmanager
def secure_temp_file(suffix: str = ".pdf"):
"""Create secure temporary file that is always cleaned up."""
tmp_path = None
try:
with tempfile.NamedTemporaryFile(
suffix=suffix,
delete=False,
dir="/tmp/invoice-master" # Dedicated temp directory
) as tmp:
tmp_path = Path(tmp.name)
yield tmp_path
finally:
if tmp_path and tmp_path.exists():
tmp_path.unlink()
# Usage
async def process_upload(file: UploadFile):
with secure_temp_file(".pdf") as tmp_path:
content = await validate_pdf_upload(file)
tmp_path.write_bytes(content)
result = pipeline.process(tmp_path)
# File automatically cleaned up
return result
```
#### Verification Steps
- [ ] Temporary files always cleaned up (use context managers)
- [ ] Temp directory has restricted permissions
- [ ] No leftover files after processing errors
### 10. Dependency Security
#### Regular Updates
```bash
# Check for vulnerabilities
pip-audit
# Update dependencies
pip install --upgrade -r requirements.txt
# Check for outdated packages
pip list --outdated
```
#### Lock Files
```bash
# Create requirements lock file
pip freeze > requirements.lock
# Install from lock file for reproducible builds
pip install -r requirements.lock
```
#### Verification Steps
- [ ] Dependencies up to date
- [ ] No known vulnerabilities (pip-audit clean)
- [ ] requirements.txt pinned versions
- [ ] Regular security updates scheduled
## Security Testing
### Automated Security Tests
```python
import pytest
from fastapi.testclient import TestClient
def test_requires_api_key(client: TestClient):
"""Test authentication required."""
response = client.post("/api/v1/infer")
assert response.status_code == 401
def test_invalid_api_key_rejected(client: TestClient):
"""Test invalid API key rejected."""
response = client.post(
"/api/v1/infer",
headers={"X-API-Key": "invalid-key"}
)
assert response.status_code == 403
def test_sql_injection_prevented(client: TestClient):
"""Test SQL injection attempt rejected."""
response = client.get(
"/api/v1/documents",
params={"id": "'; DROP TABLE documents; --"}
)
# Should return validation error, not execute SQL
assert response.status_code in (400, 422)
def test_path_traversal_prevented(client: TestClient):
"""Test path traversal attempt rejected."""
response = client.get("/api/v1/results/../../etc/passwd")
assert response.status_code == 400
def test_rate_limit_enforced(client: TestClient):
"""Test rate limiting works."""
responses = [
client.post("/api/v1/infer", files={"file": b"test"})
for _ in range(15)
]
rate_limited = [r for r in responses if r.status_code == 429]
assert len(rate_limited) > 0
def test_large_file_rejected(client: TestClient):
"""Test file size limit enforced."""
large_content = b"x" * (11 * 1024 * 1024) # 11MB
response = client.post(
"/api/v1/infer",
files={"file": ("test.pdf", large_content)}
)
assert response.status_code == 400
```
## Pre-Deployment Security Checklist
Before ANY production deployment:
- [ ] **Secrets**: No hardcoded secrets, all in env vars
- [ ] **Input Validation**: All user inputs validated with Pydantic
- [ ] **SQL Injection**: All queries use parameterized queries
- [ ] **Path Traversal**: File paths validated and sanitized
- [ ] **Authentication**: API key or token validation
- [ ] **Authorization**: Role checks in place
- [ ] **Rate Limiting**: Enabled on all endpoints
- [ ] **HTTPS**: Enforced in production
- [ ] **CORS**: Properly configured (no wildcards)
- [ ] **Error Handling**: No sensitive data in errors
- [ ] **Logging**: No sensitive data logged
- [ ] **File Uploads**: Validated (size, type, magic bytes)
- [ ] **Temp Files**: Always cleaned up
- [ ] **Dependencies**: Up to date, no vulnerabilities
## Resources
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [FastAPI Security](https://fastapi.tiangolo.com/tutorial/security/)
- [Bandit (Python Security Linter)](https://bandit.readthedocs.io/)
- [pip-audit](https://pypi.org/project/pip-audit/)
---
**Remember**: Security is not optional. One vulnerability can compromise sensitive invoice data. When in doubt, err on the side of caution.

View File

@@ -0,0 +1,63 @@
---
name: strategic-compact
description: Suggests manual context compaction at logical intervals to preserve context through task phases rather than arbitrary auto-compaction.
---
# Strategic Compact Skill
Suggests manual `/compact` at strategic points in your workflow rather than relying on arbitrary auto-compaction.
## Why Strategic Compaction?
Auto-compaction triggers at arbitrary points:
- Often mid-task, losing important context
- No awareness of logical task boundaries
- Can interrupt complex multi-step operations
Strategic compaction at logical boundaries:
- **After exploration, before execution** - Compact research context, keep implementation plan
- **After completing a milestone** - Fresh start for next phase
- **Before major context shifts** - Clear exploration context before different task
## How It Works
The `suggest-compact.sh` script runs on PreToolUse (Edit/Write) and:
1. **Tracks tool calls** - Counts tool invocations in session
2. **Threshold detection** - Suggests at configurable threshold (default: 50 calls)
3. **Periodic reminders** - Reminds every 25 calls after threshold
## Hook Setup
Add to your `~/.claude/settings.json`:
```json
{
"hooks": {
"PreToolUse": [{
"matcher": "tool == \"Edit\" || tool == \"Write\"",
"hooks": [{
"type": "command",
"command": "~/.claude/skills/strategic-compact/suggest-compact.sh"
}]
}]
}
}
```
## Configuration
Environment variables:
- `COMPACT_THRESHOLD` - Tool calls before first suggestion (default: 50)
## Best Practices
1. **Compact after planning** - Once plan is finalized, compact to start fresh
2. **Compact after debugging** - Clear error-resolution context before continuing
3. **Don't compact mid-implementation** - Preserve context for related changes
4. **Read the suggestion** - The hook tells you *when*, you decide *if*
## Related
- [The Longform Guide](https://x.com/affaanmustafa/status/2014040193557471352) - Token optimization section
- Memory persistence hooks - For state that survives compaction

View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Strategic Compact Suggester
# Runs on PreToolUse or periodically to suggest manual compaction at logical intervals
#
# Why manual over auto-compact:
# - Auto-compact happens at arbitrary points, often mid-task
# - Strategic compacting preserves context through logical phases
# - Compact after exploration, before execution
# - Compact after completing a milestone, before starting next
#
# Hook config (in ~/.claude/settings.json):
# {
# "hooks": {
# "PreToolUse": [{
# "matcher": "Edit|Write",
# "hooks": [{
# "type": "command",
# "command": "~/.claude/skills/strategic-compact/suggest-compact.sh"
# }]
# }]
# }
# }
#
# Criteria for suggesting compact:
# - Session has been running for extended period
# - Large number of tool calls made
# - Transitioning from research/exploration to implementation
# - Plan has been finalized
# Track tool call count (increment in a temp file)
COUNTER_FILE="/tmp/claude-tool-count-$$"
THRESHOLD=${COMPACT_THRESHOLD:-50}
# Initialize or increment counter
if [ -f "$COUNTER_FILE" ]; then
count=$(cat "$COUNTER_FILE")
count=$((count + 1))
echo "$count" > "$COUNTER_FILE"
else
echo "1" > "$COUNTER_FILE"
count=1
fi
# Suggest compact after threshold tool calls
if [ "$count" -eq "$THRESHOLD" ]; then
echo "[StrategicCompact] $THRESHOLD tool calls reached - consider /compact if transitioning phases" >&2
fi
# Suggest at regular intervals after threshold
if [ "$count" -gt "$THRESHOLD" ] && [ $((count % 25)) -eq 0 ]; then
echo "[StrategicCompact] $count tool calls - good checkpoint for /compact if context is stale" >&2
fi

View File

@@ -0,0 +1,553 @@
---
name: tdd-workflow
description: Use this skill when writing new features, fixing bugs, or refactoring code. Enforces test-driven development with 80%+ coverage including unit, integration, and E2E tests.
---
# Test-Driven Development Workflow
TDD principles for Python/FastAPI development with pytest.
## When to Activate
- Writing new features or functionality
- Fixing bugs or issues
- Refactoring existing code
- Adding API endpoints
- Creating new field extractors or normalizers
## Core Principles
### 1. Tests BEFORE Code
ALWAYS write tests first, then implement code to make tests pass.
### 2. Coverage Requirements
- Minimum 80% coverage (unit + integration + E2E)
- All edge cases covered
- Error scenarios tested
- Boundary conditions verified
### 3. Test Types
#### Unit Tests
- Individual functions and utilities
- Normalizers and validators
- Parsers and extractors
- Pure functions
#### Integration Tests
- API endpoints
- Database operations
- OCR + YOLO pipeline
- Service interactions
#### E2E Tests
- Complete inference pipeline
- PDF → Fields workflow
- API health and inference endpoints
## TDD Workflow Steps
### Step 1: Write User Journeys
```
As a [role], I want to [action], so that [benefit]
Example:
As an invoice processor, I want to extract Bankgiro from payment_line,
so that I can cross-validate OCR results.
```
### Step 2: Generate Test Cases
For each user journey, create comprehensive test cases:
```python
import pytest
class TestPaymentLineParser:
"""Tests for payment_line parsing and field extraction."""
def test_parse_payment_line_extracts_bankgiro(self):
"""Should extract Bankgiro from valid payment line."""
# Test implementation
pass
def test_parse_payment_line_handles_missing_checksum(self):
"""Should handle payment lines without checksum."""
pass
def test_parse_payment_line_validates_checksum(self):
"""Should validate checksum when present."""
pass
def test_parse_payment_line_returns_none_for_invalid(self):
"""Should return None for invalid payment lines."""
pass
```
### Step 3: Run Tests (They Should Fail)
```bash
pytest tests/test_ocr/test_machine_code_parser.py -v
# Tests should fail - we haven't implemented yet
```
### Step 4: Implement Code
Write minimal code to make tests pass:
```python
def parse_payment_line(line: str) -> PaymentLineData | None:
"""Parse Swedish payment line and extract fields."""
# Implementation guided by tests
pass
```
### Step 5: Run Tests Again
```bash
pytest tests/test_ocr/test_machine_code_parser.py -v
# Tests should now pass
```
### Step 6: Refactor
Improve code quality while keeping tests green:
- Remove duplication
- Improve naming
- Optimize performance
- Enhance readability
### Step 7: Verify Coverage
```bash
pytest --cov=src --cov-report=term-missing
# Verify 80%+ coverage achieved
```
## Testing Patterns
### Unit Test Pattern (pytest)
```python
import pytest
from src.normalize.bankgiro_normalizer import normalize_bankgiro
class TestBankgiroNormalizer:
"""Tests for Bankgiro normalization."""
def test_normalize_removes_hyphens(self):
"""Should remove hyphens from Bankgiro."""
result = normalize_bankgiro("123-4567")
assert result == "1234567"
def test_normalize_removes_spaces(self):
"""Should remove spaces from Bankgiro."""
result = normalize_bankgiro("123 4567")
assert result == "1234567"
def test_normalize_validates_length(self):
"""Should validate Bankgiro is 7-8 digits."""
result = normalize_bankgiro("123456") # 6 digits
assert result is None
def test_normalize_validates_checksum(self):
"""Should validate Luhn checksum."""
result = normalize_bankgiro("1234568") # Invalid checksum
assert result is None
@pytest.mark.parametrize("input_value,expected", [
("123-4567", "1234567"),
("1234567", "1234567"),
("123 4567", "1234567"),
("BG 123-4567", "1234567"),
])
def test_normalize_various_formats(self, input_value, expected):
"""Should handle various input formats."""
result = normalize_bankgiro(input_value)
assert result == expected
```
### API Integration Test Pattern
```python
import pytest
from fastapi.testclient import TestClient
from src.web.app import app
@pytest.fixture
def client():
return TestClient(app)
class TestHealthEndpoint:
"""Tests for /api/v1/health endpoint."""
def test_health_returns_200(self, client):
"""Should return 200 OK."""
response = client.get("/api/v1/health")
assert response.status_code == 200
def test_health_returns_status(self, client):
"""Should return health status."""
response = client.get("/api/v1/health")
data = response.json()
assert data["status"] == "healthy"
assert "model_loaded" in data
class TestInferEndpoint:
"""Tests for /api/v1/infer endpoint."""
def test_infer_requires_file(self, client):
"""Should require file upload."""
response = client.post("/api/v1/infer")
assert response.status_code == 422
def test_infer_rejects_non_pdf(self, client):
"""Should reject non-PDF files."""
response = client.post(
"/api/v1/infer",
files={"file": ("test.txt", b"not a pdf", "text/plain")}
)
assert response.status_code == 400
def test_infer_returns_fields(self, client, sample_invoice_pdf):
"""Should return extracted fields."""
with open(sample_invoice_pdf, "rb") as f:
response = client.post(
"/api/v1/infer",
files={"file": ("invoice.pdf", f, "application/pdf")}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "fields" in data
```
### E2E Test Pattern
```python
import pytest
import httpx
from pathlib import Path
@pytest.fixture(scope="module")
def running_server():
"""Ensure server is running for E2E tests."""
# Server should be started before running E2E tests
base_url = "http://localhost:8000"
yield base_url
class TestInferencePipeline:
"""E2E tests for complete inference pipeline."""
def test_health_check(self, running_server):
"""Should pass health check."""
response = httpx.get(f"{running_server}/api/v1/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert data["model_loaded"] is True
def test_pdf_inference_returns_fields(self, running_server):
"""Should extract fields from PDF."""
pdf_path = Path("tests/fixtures/sample_invoice.pdf")
with open(pdf_path, "rb") as f:
response = httpx.post(
f"{running_server}/api/v1/infer",
files={"file": ("invoice.pdf", f, "application/pdf")}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "fields" in data
assert len(data["fields"]) > 0
def test_cross_validation_included(self, running_server):
"""Should include cross-validation for invoices with payment_line."""
pdf_path = Path("tests/fixtures/invoice_with_payment_line.pdf")
with open(pdf_path, "rb") as f:
response = httpx.post(
f"{running_server}/api/v1/infer",
files={"file": ("invoice.pdf", f, "application/pdf")}
)
data = response.json()
if data["fields"].get("payment_line"):
assert "cross_validation" in data
```
## Test File Organization
```
tests/
├── conftest.py # Shared fixtures
├── fixtures/ # Test data files
│ ├── sample_invoice.pdf
│ └── invoice_with_payment_line.pdf
├── test_cli/
│ └── test_infer.py
├── test_pdf/
│ ├── test_extractor.py
│ └── test_renderer.py
├── test_ocr/
│ ├── test_paddle_ocr.py
│ └── test_machine_code_parser.py
├── test_inference/
│ ├── test_pipeline.py
│ ├── test_yolo_detector.py
│ └── test_field_extractor.py
├── test_normalize/
│ ├── test_bankgiro_normalizer.py
│ ├── test_date_normalizer.py
│ └── test_amount_normalizer.py
├── test_web/
│ ├── test_routes.py
│ └── test_services.py
└── e2e/
└── test_inference_e2e.py
```
## Mocking External Services
### Mock PaddleOCR
```python
import pytest
from unittest.mock import Mock, patch
@pytest.fixture
def mock_paddle_ocr():
"""Mock PaddleOCR for unit tests."""
with patch("src.ocr.paddle_ocr.PaddleOCR") as mock:
instance = Mock()
instance.ocr.return_value = [
[
[[[0, 0], [100, 0], [100, 20], [0, 20]], ("Invoice Number", 0.95)],
[[[0, 30], [100, 30], [100, 50], [0, 50]], ("INV-2024-001", 0.98)]
]
]
mock.return_value = instance
yield instance
```
### Mock YOLO Model
```python
@pytest.fixture
def mock_yolo_model():
"""Mock YOLO model for unit tests."""
with patch("src.inference.yolo_detector.YOLO") as mock:
instance = Mock()
# Mock detection results
instance.return_value = Mock(
boxes=Mock(
xyxy=[[10, 20, 100, 50]],
conf=[0.95],
cls=[0] # invoice_number class
)
)
mock.return_value = instance
yield instance
```
### Mock Database
```python
@pytest.fixture
def mock_db_connection():
"""Mock database connection for unit tests."""
with patch("src.data.db.get_db_connection") as mock:
conn = Mock()
cursor = Mock()
cursor.fetchall.return_value = [
("doc-123", "processed", {"invoice_number": "INV-001"})
]
cursor.fetchone.return_value = ("doc-123",)
conn.cursor.return_value.__enter__ = Mock(return_value=cursor)
conn.cursor.return_value.__exit__ = Mock(return_value=False)
mock.return_value.__enter__ = Mock(return_value=conn)
mock.return_value.__exit__ = Mock(return_value=False)
yield conn
```
## Test Coverage Verification
### Run Coverage Report
```bash
# Run with coverage
pytest --cov=src --cov-report=term-missing
# Generate HTML report
pytest --cov=src --cov-report=html
# Open htmlcov/index.html in browser
```
### Coverage Configuration (pyproject.toml)
```toml
[tool.coverage.run]
source = ["src"]
omit = ["*/__init__.py", "*/test_*.py"]
[tool.coverage.report]
fail_under = 80
show_missing = true
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
```
## Common Testing Mistakes to Avoid
### WRONG: Testing Implementation Details
```python
# Don't test internal state
def test_parser_internal_state():
parser = PaymentLineParser()
parser._parse("...")
assert parser._groups == [...] # Internal state
```
### CORRECT: Test Public Interface
```python
# Test what users see
def test_parser_extracts_bankgiro():
result = parse_payment_line("...")
assert result.bankgiro == "1234567"
```
### WRONG: No Test Isolation
```python
# Tests depend on each other
class TestDocuments:
def test_creates_document(self):
create_document(...) # Creates in DB
def test_updates_document(self):
update_document(...) # Depends on previous test
```
### CORRECT: Independent Tests
```python
# Each test sets up its own data
class TestDocuments:
def test_creates_document(self, mock_db):
result = create_document(...)
assert result.id is not None
def test_updates_document(self, mock_db):
# Create own test data
doc = create_document(...)
result = update_document(doc.id, ...)
assert result.status == "updated"
```
### WRONG: Testing Too Much
```python
# One test doing everything
def test_full_invoice_processing():
# Load PDF
# Extract images
# Run YOLO
# Run OCR
# Normalize fields
# Save to DB
# Return response
```
### CORRECT: Focused Tests
```python
def test_yolo_detects_invoice_number():
"""Test only YOLO detection."""
result = detector.detect(image)
assert any(d.label == "invoice_number" for d in result)
def test_ocr_extracts_text():
"""Test only OCR extraction."""
result = ocr.extract(image, bbox)
assert result == "INV-2024-001"
def test_normalizer_formats_date():
"""Test only date normalization."""
result = normalize_date("2024-01-15")
assert result == "2024-01-15"
```
## Fixtures (conftest.py)
```python
import pytest
from pathlib import Path
from fastapi.testclient import TestClient
@pytest.fixture
def sample_invoice_pdf(tmp_path: Path) -> Path:
"""Create sample invoice PDF for testing."""
pdf_path = tmp_path / "invoice.pdf"
# Copy from fixtures or create minimal PDF
src = Path("tests/fixtures/sample_invoice.pdf")
if src.exists():
pdf_path.write_bytes(src.read_bytes())
return pdf_path
@pytest.fixture
def client():
"""FastAPI test client."""
from src.web.app import app
return TestClient(app)
@pytest.fixture
def sample_payment_line() -> str:
"""Sample Swedish payment line for testing."""
return "1234567#0000000012345#230115#00012345678901234567#1"
```
## Continuous Testing
### Watch Mode During Development
```bash
# Using pytest-watch
ptw -- tests/test_ocr/
# Tests run automatically on file changes
```
### Pre-Commit Hook
```bash
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: pytest
name: pytest
entry: pytest --tb=short -q
language: system
pass_filenames: false
always_run: true
```
### CI/CD Integration (GitHub Actions)
```yaml
- name: Run Tests
run: |
pytest --cov=src --cov-report=xml
- name: Upload Coverage
uses: codecov/codecov-action@v3
with:
file: coverage.xml
```
## Best Practices
1. **Write Tests First** - Always TDD
2. **One Assert Per Test** - Focus on single behavior
3. **Descriptive Test Names** - `test_<what>_<condition>_<expected>`
4. **Arrange-Act-Assert** - Clear test structure
5. **Mock External Dependencies** - Isolate unit tests
6. **Test Edge Cases** - None, empty, invalid, boundary
7. **Test Error Paths** - Not just happy paths
8. **Keep Tests Fast** - Unit tests < 50ms each
9. **Clean Up After Tests** - Use fixtures with cleanup
10. **Review Coverage Reports** - Identify gaps
## Success Metrics
- 80%+ code coverage achieved
- All tests passing (green)
- No skipped or disabled tests
- Fast test execution (< 60s for unit tests)
- E2E tests cover critical inference flow
- Tests catch bugs before production
---
**Remember**: Tests are not optional. They are the safety net that enables confident refactoring, rapid development, and production reliability.

View File

@@ -0,0 +1,139 @@
---
name: ui-prompt-generator
description: 读取 Product-Spec.md 中的功能需求和 UI 布局,生成可用于 AI 绘图工具的原型图提示词。与 product-spec-builder 配套使用,帮助用户快速将需求文档转化为视觉原型。
---
[角色]
你是一位 UI/UX 设计专家,擅长将产品需求转化为精准的视觉描述。
你能够从结构化的产品文档中提取关键信息,并转化为 AI 绘图工具可以理解的提示词,帮助用户快速生成产品原型图。
[任务]
读取 Product-Spec.md提取功能需求和 UI 布局信息,补充必要的视觉参数,生成可直接用于文生图工具的原型图提示词。
最终输出按页面拆分的提示词,用户可以直接复制到 AI 绘图工具生成原型图。
[技能]
- **文档解析**:从 Product-Spec.md 提取产品概述、功能需求、UI 布局、用户流程
- **页面识别**:根据产品复杂度识别需要生成几个页面
- **视觉转换**:将结构化的布局描述转化为视觉语言
- **提示词生成**:输出高质量的英文文生图提示词
[文件结构]
```
ui-prompt-generator/
├── SKILL.md # 主 Skill 定义(本文件)
└── templates/
└── ui-prompt-template.md # 提示词输出模板
```
[总体规则]
- 始终使用中文与用户交流
- 提示词使用英文输出AI 绘图工具英文效果更好)
- 必须先读取 Product-Spec.md不存在则提示用户先完成需求收集
- 不重复追问 Product-Spec.md 里已有的信息
- 用户不确定的信息,直接使用默认值继续推进
- 按页面拆分生成提示词,每个页面一条提示词
- 保持专业友好的语气
[视觉风格选项]
| 风格 | 英文 | 说明 | 适用场景 |
|------|------|------|---------|
| 现代极简 | Minimalism | 简洁留白、干净利落 | 工具类、企业应用 |
| 玻璃拟态 | Glassmorphism | 毛玻璃效果、半透明层叠 | 科技产品、仪表盘 |
| 新拟态 | Neomorphism | 柔和阴影、微凸起效果 | 音乐播放器、控制面板 |
| 便当盒布局 | Bento Grid | 模块化卡片、网格排列 | 数据展示、功能聚合页 |
| 暗黑模式 | Dark Mode | 深色背景、低亮度护眼 | 开发工具、影音类 |
| 新野兽派 | Neo-Brutalism | 粗黑边框、高对比、大胆配色 | 创意类、潮流品牌 |
**默认值**现代极简Minimalism
[配色选项]
| 选项 | 说明 |
|------|------|
| 浅色系 | 白色/浅灰背景,深色文字 |
| 深色系 | 深色/黑色背景,浅色文字 |
| 指定主色 | 用户指定品牌色或主题色 |
**默认值**:浅色系
[目标平台选项]
| 选项 | 说明 |
|------|------|
| 桌面端 | Desktop application宽屏布局 |
| 网页 | Web application响应式布局 |
| 移动端 | Mobile application竖屏布局 |
**默认值**:网页
[工作流程]
[启动阶段]
目的:读取 Product-Spec.md提取信息补充缺失的视觉参数
第一步:检测文件
检测项目目录中是否存在 Product-Spec.md
不存在 → 提示:「未找到 Product-Spec.md请先使用 /prd 完成需求收集。」,终止流程
存在 → 继续
第二步:解析 Product-Spec.md
读取 Product-Spec.md 文件内容
提取以下信息:
- 产品概述:了解产品是什么
- 功能需求:了解有哪些功能
- UI 布局:了解界面结构和控件
- 用户流程:了解有哪些页面和状态
- 视觉风格(如果文档里提到了)
- 配色方案(如果文档里提到了)
- 目标平台(如果文档里提到了)
第三步:识别页面
根据 UI 布局和用户流程,识别产品包含几个页面
判断逻辑:
- 只有一个主界面 → 单页面产品
- 有多个界面(如:主界面、设置页、详情页)→ 多页面产品
- 有明显的多步骤流程 → 按步骤拆分页面
输出页面清单:
"📄 **识别到以下页面:**
1. [页面名称][简要描述]
2. [页面名称][简要描述]
..."
第四步:补充缺失的视觉参数
检查是否已提取到:视觉风格、配色方案、目标平台
全部已有 → 跳过提问,直接进入提示词生成阶段
有缺失项 → 只针对缺失项询问用户:
"🎨 **还需要确认几个视觉参数:**
[只列出缺失的项目,已有的不列]
直接回复你的选择,或回复「默认」使用默认值。"
用户回复后解析选择
用户不确定或回复「默认」→ 使用默认值
[提示词生成阶段]
目的:为每个页面生成提示词
第一步:准备生成参数
整合所有信息:
- 产品类型(从产品概述提取)
- 页面列表(从启动阶段获取)
- 每个页面的布局和控件(从 UI 布局提取)
- 视觉风格(从 Product-Spec.md 提取或用户选择)
- 配色方案(从 Product-Spec.md 提取或用户选择)
- 目标平台(从 Product-Spec.md 提取或用户选择)
第二步:按页面生成提示词
加载 templates/ui-prompt-template.md 获取提示词结构和输出格式
为每个页面生成一条英文提示词
按模板中的提示词结构组织内容
第三步:输出文件
将生成的提示词保存为 UI-Prompts.md
[初始化]
执行 [启动阶段]

View File

@@ -0,0 +1,154 @@
---
name: ui-prompt-template
description: UI 原型图提示词输出模板。当需要生成文生图提示词时,按照此模板的结构和格式填充内容,输出为 UI-Prompts.md 文件。
---
# UI 原型图提示词模板
本模板用于生成可直接用于 AI 绘图工具的原型图提示词。生成时按照此结构填充内容。
---
## 文件命名
`UI-Prompts.md`
---
## 提示词结构
每条提示词按以下结构组织:
```
[主体] + [布局] + [控件] + [风格] + [质量词]
```
### [主体]
产品类型 + 界面类型 + 页面名称
示例:
- `A modern web application UI for a storyboard generator tool, main interface`
- `A mobile app screen for a task management application, settings page`
### [布局]
整体结构 + 比例 + 区域划分
示例:
- `split layout with left panel (40%) and right content area (60%)`
- `single column layout with top navigation bar and main content below`
- `grid layout with 2x2 card arrangement`
### [控件]
各区域的具体控件,从上到下、从左到右描述
示例:
- `left panel contains: project name input at top, large text area for content, dropdown menu for style selection, primary action button at bottom`
- `right panel shows: 3x3 grid of image cards with frame numbers and captions, action buttons below`
### [风格]
视觉风格 + 配色 + 细节特征
| 风格 | 英文描述 |
|------|---------|
| 现代极简 | minimalist design, clean layout, ample white space, subtle shadows |
| 玻璃拟态 | glassmorphism style, frosted glass effect, translucent panels, blur background |
| 新拟态 | neumorphism design, soft shadows, subtle highlights, extruded elements |
| 便当盒布局 | bento grid layout, modular cards, organized sections, clean borders |
| 暗黑模式 | dark mode UI, dark background, light text, subtle glow effects |
| 新野兽派 | neo-brutalist design, bold black borders, high contrast, raw aesthetic |
配色描述:
- 浅色系:`light color scheme, white background, dark text, [accent color] accent`
- 深色系:`dark color scheme, dark gray background, light text, [accent color] accent`
### [质量词]
确保生成质量的关键词,放在提示词末尾
```
UI/UX design, high fidelity mockup, 4K resolution, professional, Figma style, dribbble, behance
```
---
## 输出格式
```markdown
# [产品名称] 原型图提示词
> 视觉风格:[风格名称]
> 配色方案:[配色名称]
> 目标平台:[平台名称]
---
## 页面 1[页面名称]
**页面说明**[一句话描述这个页面是什么]
**提示词**
```
[完整的英文提示词]
```
---
## 页面 2[页面名称]
**页面说明**[一句话描述]
**提示词**
```
[完整的英文提示词]
```
```
---
## 完整示例
以下是「剧本分镜生成器」的原型图提示词示例,供参考:
```markdown
# 剧本分镜生成器 原型图提示词
> 视觉风格现代极简Minimalism
> 配色方案:浅色系
> 目标平台网页Web
---
## 页面 1主界面
**页面说明**:用户输入剧本、设置角色和场景、生成分镜图的主要工作界面
**提示词**
```
A modern web application UI for a storyboard generator tool, main interface, split layout with left input panel (40% width) and right output area (60% width), left panel contains: project name input field at top, large multiline text area for script input with placeholder text, character cards section with image thumbnails and text fields and add button, scene cards section below, style dropdown menu, prominent generate button at bottom, right panel shows: 3x3 grid of storyboard image cards with frame numbers and short descriptions below each image, download all button and continue generating button below the grid, page navigation at bottom, minimalist design, clean layout, white background, light gray borders, blue accent color for primary actions, subtle shadows, rounded corners, UI/UX design, high fidelity mockup, 4K resolution, professional, Figma style
```
---
## 页面 2空状态界面
**页面说明**:用户首次打开、尚未输入内容时的引导界面
**提示词**
```
A modern web application UI for a storyboard generator tool, empty state screen, split layout with left panel (40%) and right panel (60%), left panel shows: empty input fields with placeholder text and helper icons, right panel displays: large empty state illustration in the center, welcome message and getting started tips below, minimalist design, clean layout, white background, soft gray placeholder elements, blue accent color, friendly and inviting atmosphere, UI/UX design, high fidelity mockup, 4K resolution, professional, Figma style
```
```
---
## 写作要点
1. **提示词语言**始终使用英文AI 绘图工具对英文理解更好
2. **结构完整**:确保包含主体、布局、控件、风格、质量词五个部分
3. **控件描述**
- 按空间顺序描述(上到下、左到右)
- 具体到控件类型input field, button, dropdown, card
- 包含控件状态placeholder text, selected state
4. **布局比例**写明具体比例40%/60%),不要只说「左右布局」
5. **风格一致**:同一产品的多个页面使用相同的风格描述
6. **质量词**:始终在末尾加上质量词确保生成效果
7. **页面说明**:用中文写一句话说明,帮助理解这个页面是什么

View File

@@ -0,0 +1,242 @@
# Verification Loop Skill
Comprehensive verification system for Python/FastAPI development.
## When to Use
Invoke this skill:
- After completing a feature or significant code change
- Before creating a PR
- When you want to ensure quality gates pass
- After refactoring
- Before deployment
## Verification Phases
### Phase 1: Type Check
```bash
# Run mypy type checker
mypy src/ --ignore-missing-imports 2>&1 | head -30
```
Report all type errors. Fix critical ones before continuing.
### Phase 2: Lint Check
```bash
# Run ruff linter
ruff check src/ 2>&1 | head -30
# Auto-fix if desired
ruff check src/ --fix
```
Check for:
- Unused imports
- Code style violations
- Common Python anti-patterns
### Phase 3: Test Suite
```bash
# Run tests with coverage
pytest --cov=src --cov-report=term-missing -q 2>&1 | tail -50
# Run specific test file
pytest tests/test_ocr/test_machine_code_parser.py -v
# Run with short traceback
pytest -x --tb=short
```
Report:
- Total tests: X
- Passed: X
- Failed: X
- Coverage: X%
- Target: 80% minimum
### Phase 4: Security Scan
```bash
# Check for hardcoded secrets
grep -rn "password\s*=" --include="*.py" src/ 2>/dev/null | grep -v "db_password:" | head -10
grep -rn "api_key\s*=" --include="*.py" src/ 2>/dev/null | head -10
grep -rn "sk-" --include="*.py" src/ 2>/dev/null | head -10
# Check for print statements (should use logging)
grep -rn "print(" --include="*.py" src/ 2>/dev/null | head -10
# Check for bare except
grep -rn "except:" --include="*.py" src/ 2>/dev/null | head -10
# Check for SQL injection risks (f-strings in execute)
grep -rn 'execute(f"' --include="*.py" src/ 2>/dev/null | head -10
grep -rn "execute(f'" --include="*.py" src/ 2>/dev/null | head -10
```
### Phase 5: Import Check
```bash
# Verify all imports work
python -c "from src.web.app import app; print('Web app OK')"
python -c "from src.inference.pipeline import InferencePipeline; print('Pipeline OK')"
python -c "from src.ocr.machine_code_parser import parse_payment_line; print('Parser OK')"
```
### Phase 6: Diff Review
```bash
# Show what changed
git diff --stat
git diff HEAD --name-only
# Show staged changes
git diff --staged --stat
```
Review each changed file for:
- Unintended changes
- Missing error handling
- Potential edge cases
- Missing type hints
- Mutable default arguments
### Phase 7: API Smoke Test (if server running)
```bash
# Health check
curl -s http://localhost:8000/api/v1/health | python -m json.tool
# Verify response format
curl -s http://localhost:8000/api/v1/health | grep -q "healthy" && echo "Health: OK" || echo "Health: FAIL"
```
## Output Format
After running all phases, produce a verification report:
```
VERIFICATION REPORT
==================
Types: [PASS/FAIL] (X errors)
Lint: [PASS/FAIL] (X warnings)
Tests: [PASS/FAIL] (X/Y passed, Z% coverage)
Security: [PASS/FAIL] (X issues)
Imports: [PASS/FAIL]
Diff: [X files changed]
Overall: [READY/NOT READY] for PR
Issues to Fix:
1. ...
2. ...
```
## Quick Commands
```bash
# Full verification (WSL)
wsl bash -c "source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && mypy src/ --ignore-missing-imports && ruff check src/ && pytest -x --tb=short"
# Type check only
wsl bash -c "source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && mypy src/ --ignore-missing-imports"
# Tests only
wsl bash -c "source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && cd /mnt/c/Users/yaoji/git/ColaCoder/invoice-master-poc-v2 && pytest --cov=src -q"
```
## Verification Checklist
### Before Commit
- [ ] mypy passes (no type errors)
- [ ] ruff check passes (no lint errors)
- [ ] All tests pass
- [ ] No print() statements in production code
- [ ] No hardcoded secrets
- [ ] No bare `except:` clauses
- [ ] No SQL injection risks (f-strings in queries)
- [ ] Coverage >= 80% for changed code
### Before PR
- [ ] All above checks pass
- [ ] git diff reviewed for unintended changes
- [ ] New code has tests
- [ ] Type hints on all public functions
- [ ] Docstrings on public APIs
- [ ] No TODO/FIXME for critical items
### Before Deployment
- [ ] All above checks pass
- [ ] E2E tests pass
- [ ] Health check returns healthy
- [ ] Model loaded successfully
- [ ] No server errors in logs
## Common Issues and Fixes
### Type Error: Missing return type
```python
# Before
def process(data):
return result
# After
def process(data: dict) -> InferenceResult:
return result
```
### Lint Error: Unused import
```python
# Remove unused imports or add to __all__
```
### Security: print() in production
```python
# Before
print(f"Processing {doc_id}")
# After
logger.info(f"Processing {doc_id}")
```
### Security: Bare except
```python
# Before
except:
pass
# After
except Exception as e:
logger.error(f"Error: {e}")
raise
```
### Security: SQL injection
```python
# Before (DANGEROUS)
cur.execute(f"SELECT * FROM docs WHERE id = '{user_input}'")
# After (SAFE)
cur.execute("SELECT * FROM docs WHERE id = %s", (user_input,))
```
## Continuous Mode
For long sessions, run verification after major changes:
```markdown
Checkpoints:
- After completing each function
- After finishing a module
- Before moving to next task
- Every 15-20 minutes of coding
Run: /verify
```
## Integration with Other Skills
| Skill | Purpose |
|-------|---------|
| code-review | Detailed code analysis |
| security-review | Deep security audit |
| tdd-workflow | Test coverage |
| build-fix | Fix errors incrementally |
This skill provides quick, comprehensive verification. Use specialized skills for deeper analysis.

194
AGENTS.md Normal file
View File

@@ -0,0 +1,194 @@
# Invoice Master - Multi-Accounting System Integration
Invoice Master - 多会计系统集成平台,支持 Fortnox、Visma、Hogia 等瑞典及北欧会计软件。
## Tech Stack
| Component | Technology |
|-----------|------------|
| Backend Framework | .NET 10 + ASP.NET Core |
| Database | PostgreSQL + EF Core |
| ORM | Entity Framework Core |
| API Documentation | Swagger/OpenAPI |
| Authentication | JWT Bearer Tokens |
| HTTP Client | HttpClient + Polly |
| Logging | Serilog + Structured Logging |
| Testing | xUnit + Moq + FluentAssertions |
| Frontend | React 18 + TypeScript + Vite |
## Development Environment
### Prerequisites
- .NET 10 SDK
- Node.js 18+
- PostgreSQL 15+
- Redis 7+ (optional, for caching)
### Running the Application
```bash
# Backend
cd backend
dotnet restore
dotnet run --project src/InvoiceMaster.API
# Frontend
cd frontend
npm install
npm run dev
```
## Project-Specific Rules
### .NET Development
- Use .NET 8 with C# 12 features
- Use primary constructors where appropriate
- Use `required` properties for mandatory fields
- Use `record` types for DTOs and immutable data
- Use `IResult` for minimal API responses
- Use `ProblemDetails` for error responses
- No `Console.WriteLine()` in production - use `ILogger<T>`
- Run tests: `dotnet test --verbosity normal`
### Entity Framework
- Use Code-First migrations
- Use `IQueryable` for database queries
- Use eager loading (`Include`, `ThenInclude`) to avoid N+1
- Use raw SQL only when necessary with `FromSqlRaw`
- Always use parameterized queries
- Migrations naming: `Add{FeatureName}To{TableName}`
### Async/Await Best Practices
- Use `async`/`await` for all I/O operations
- Use `CancellationToken` for cancellable operations
- Avoid `async void` - use `async Task` instead
- Use `ConfigureAwait(false)` in library code
- Name async methods with `Async` suffix
## Critical Rules
### Code Organization
- Many small files over few large files
- High cohesion, low coupling
- 200-400 lines typical, 800 max per file
- Organize by feature/domain, not by type
- Use vertical slice architecture for features
### Code Style
- No emojis in code, comments, or documentation
- Immutability always - never mutate objects or arrays
- No `console.log` in production code
- Proper error handling with try/catch
- Input validation with FluentValidation or Data Annotations
- Use `readonly` for fields that don't change after construction
### Testing
- TDD: Write tests first
- 80% minimum coverage
- Unit tests for utilities and services
- Integration tests for APIs (use TestServer)
- E2E tests for critical flows
- Use `IClassFixture` for shared test context
- Use `CollectionDefinition` for test collections
### Security
- No hardcoded secrets
- Environment variables for sensitive data
- Validate all user inputs
- Use parameterized queries (EF Core does this automatically)
- Enable CSRF protection
- Use HTTPS redirection in production
- Store passwords with bcrypt/Argon2 (use Identity)
## Environment Variables
```bash
# Required
DB_PASSWORD=
JWT_SECRET_KEY=
# Optional (with defaults)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=invoice_master
DB_USER=postgres
ASPNETCORE_ENVIRONMENT=Development
ASPNETCORE_URLS=http://localhost:5000
# Provider-specific (at least one required)
FORTNOX_CLIENT_ID=
FORTNOX_CLIENT_SECRET=
FORTNOX_REDIRECT_URI=http://localhost:5173/accounting/fortnox/callback
# OCR API
OCR_API_URL=http://localhost:8000/api/v1
OCR_API_KEY=
# Azure Blob Storage
AZURE_STORAGE_CONNECTION_STRING=
AZURE_STORAGE_CONTAINER=documents
```
## Available Commands
- `/tdd` - Test-driven development workflow
- `/plan` - Create implementation plan
- `/code-review` - Review code quality
- `/build-fix` - Fix build errors
## Git Workflow
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`
- Never commit to main directly
- PRs require review
- All tests must pass before merge
- Commit the code for each phase
## Project Structure
```
backend/
├── src/
│ ├── InvoiceMaster.API/ # Web API entry point
│ ├── InvoiceMaster.Core/ # Domain models, interfaces
│ ├── InvoiceMaster.Application/ # Business logic, services
│ ├── InvoiceMaster.Infrastructure/ # EF Core, external clients
│ └── InvoiceMaster.Integrations/ # Accounting system providers
└── tests/
├── InvoiceMaster.UnitTests/
├── InvoiceMaster.IntegrationTests/
└── InvoiceMaster.ArchitectureTests/
```
## Useful Guides
- API_DESIGN.md
- ARCHITECTURE.md
- DATABASE_SCHEMA.md
- DEPLOYMENT_GUIDE.md
- DEVELOPMENT_PLAN.md
- DIRECTORY_STRUCTURE.md
## Project Memory Rules
Must proactively invoke the progress-recorder skill to record key information such as important decisions, task changes, completed items, etc., into progress.md.
Automatically trigger progress-recorder immediately when any of the following are detected:
Decision-related language such as “decide to use / final choice / will adopt”
Constraint-related language such as “must / must not / required”
Completion indicators such as “completed / implemented / fixed”
New task indicators such as “need to / should / plan to”
When the Notes/Done entries in progress.md become too numerous (>100 items) and affect readability, they should be archived to progress.archive.md.

877
API_DESIGN.md Normal file
View File

@@ -0,0 +1,877 @@
# Invoice Master - API 设计文档
**版本**: v3.0
**Base URL**: `https://api.invoice-master.app/api/v1`
**日期**: 2026-02-03
**技术栈**: .NET 8 + ASP.NET Core + MediatR (CQRS)
---
## 1. 概述
### 1.1 多会计系统支持
本 API 支持连接多个会计系统Fortnox, Visma, Hogia 等),通过统一的抽象层提供一致的接口。
**Provider 标识:**
- `fortnox` - Fortnox (瑞典)
- `visma` - Visma eAccounting (北欧)
- `hogia` - Hogia Smart (瑞典)
### 1.2 认证方式
API 使用 **JWT Bearer Token** 进行认证:
```http
Authorization: Bearer <jwt_token>
```
### 1.3 响应格式
所有响应使用 JSON 格式,统一结构:
```json
{
"success": true,
"data": { ... },
"meta": {
"request_id": "req_abc123",
"timestamp": "2026-02-03T10:30:00Z"
}
}
```
错误响应:
```json
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Human readable message",
"details": { ... }
},
"meta": {
"request_id": "req_abc123",
"timestamp": "2026-02-03T10:30:00Z"
}
}
```
### 1.4 HTTP 状态码
| 状态码 | 含义 |
|--------|------|
| 200 | 成功 |
| 201 | 创建成功 |
| 400 | 请求参数错误 |
| 401 | 未认证 |
| 403 | 无权限 |
| 404 | 资源不存在 |
| 409 | 资源冲突 |
| 422 | 业务逻辑错误 |
| 429 | 请求过于频繁 |
| 500 | 服务器错误 |
---
## 2. 认证相关
### 2.1 用户注册
```http
POST /auth/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123!",
"full_name": "John Doe"
}
```
**响应:**
```json
{
"success": true,
"data": {
"user": {
"id": "uuid",
"email": "user@example.com",
"full_name": "John Doe",
"created_at": "2026-02-03T10:30:00Z"
},
"tokens": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"expires_in": 900
}
}
}
```
### 2.2 用户登录
```http
POST /auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123!"
}
```
**响应:**
```json
{
"success": true,
"data": {
"user": {
"id": "uuid",
"email": "user@example.com",
"full_name": "John Doe",
"connections": [
{
"provider": "fortnox",
"connected": true,
"company_name": "My Company AB"
}
]
},
"tokens": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"expires_in": 900
}
}
}
```
### 2.3 刷新 Token
```http
POST /auth/refresh
Content-Type: application/json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}
```
### 2.4 登出
```http
POST /auth/logout
Authorization: Bearer <token>
```
---
## 3. 会计系统集成 (通用接口)
### 3.1 获取支持的会计系统列表
```http
GET /accounting/providers
Authorization: Bearer <token>
```
**响应:**
```json
{
"success": true,
"data": {
"providers": [
{
"id": "fortnox",
"name": "Fortnox",
"description": "Swedish accounting software",
"available": true,
"connected": true
},
{
"id": "visma",
"name": "Visma eAccounting",
"description": "Nordic accounting software",
"available": true,
"connected": false
},
{
"id": "hogia",
"name": "Hogia Smart",
"description": "Swedish accounting software",
"available": false,
"connected": false
}
]
}
}
```
### 3.2 获取授权 URL
```http
GET /accounting/{provider}/auth/url
Authorization: Bearer <token>
```
**参数:**
| 参数 | 类型 | 说明 |
|------|------|------|
| provider | string | 会计系统标识 (fortnox, visma, hogia) |
**响应:**
```json
{
"success": true,
"data": {
"provider": "fortnox",
"authorization_url": "https://apps.fortnox.se/oauth-v1/auth?client_id=xxx&redirect_uri=...&scope=...&state=...",
"state": "random_state_string"
}
}
```
### 3.3 OAuth 回调处理
```http
GET /accounting/{provider}/auth/callback?code=xxx&state=xxx
```
**响应:**
```json
{
"success": true,
"data": {
"provider": "fortnox",
"connected": true,
"company_name": "My Company AB",
"company_org_number": "556677-8899",
"connected_at": "2026-02-03T10:30:00Z"
}
}
```
### 3.4 获取用户的所有连接
```http
GET /accounting/connections
Authorization: Bearer <token>
```
**响应:**
```json
{
"success": true,
"data": {
"connections": [
{
"provider": "fortnox",
"connected": true,
"company_name": "My Company AB",
"company_org_number": "556677-8899",
"scopes": ["supplier", "voucher", "account"],
"expires_at": "2026-02-03T11:30:00Z",
"settings": {
"default_voucher_series": "A",
"default_account_code": 5460,
"auto_attach_pdf": true,
"auto_create_supplier": false
}
}
]
}
}
```
### 3.5 获取特定连接状态
```http
GET /accounting/connections/{provider}
Authorization: Bearer <token>
```
**响应:**
```json
{
"success": true,
"data": {
"provider": "fortnox",
"connected": true,
"company_name": "My Company AB",
"company_org_number": "556677-8899",
"scopes": ["supplier", "voucher", "account"],
"expires_at": "2026-02-03T11:30:00Z"
}
}
```
### 3.6 更新连接设置
```http
PATCH /accounting/connections/{provider}/settings
Authorization: Bearer <token>
Content-Type: application/json
{
"default_voucher_series": "A",
"default_account_code": 5460,
"auto_attach_pdf": true,
"auto_create_supplier": false
}
```
### 3.7 断开连接
```http
DELETE /accounting/connections/{provider}
Authorization: Bearer <token>
```
---
## 4. 发票处理
### 4.1 上传发票
```http
POST /invoices
Authorization: Bearer <token>
Content-Type: multipart/form-data
file: <binary>
provider: "fortnox" # 目标会计系统
auto_process: false # 是否自动处理
```
**响应 (预览模式):**
```json
{
"success": true,
"data": {
"id": "inv_uuid",
"status": "preview",
"provider": "fortnox",
"file": {
"name": "Invoice_2024_001.pdf",
"size": 1024567,
"url": "https://blob.azure/..."
},
"extraction": {
"supplier_name": "ABC Company",
"supplier_org_number": "556677-8899",
"invoice_number": "F2024-001",
"invoice_date": "2024-01-15",
"due_date": "2024-02-15",
"amount_total": 1250.00,
"amount_vat": 250.00,
"vat_rate": 25,
"ocr_number": "7350012345678",
"bankgiro": "123-4567",
"currency": "SEK",
"confidence": 0.95
},
"supplier_match": {
"action": "USE_EXISTING",
"supplier_number": "123",
"supplier_name": "ABC Company",
"confidence": 1.0
},
"voucher_preview": {
"series": "A",
"rows": [
{
"account": 5460,
"account_name": "Kontorsmaterial",
"debit": 1000.00,
"credit": 0,
"description": "ABC Company - F2024-001"
},
{
"account": 2610,
"account_name": "Ingående moms",
"debit": 250.00,
"credit": 0,
"description": "Moms 25%"
},
{
"account": 2440,
"account_name": "Leverantörsskulder",
"debit": 0,
"credit": 1250.00,
"description": "Faktura F2024-001",
"supplier_number": "123"
}
]
},
"created_at": "2026-02-03T10:30:00Z"
}
}
```
### 4.2 获取发票列表
```http
GET /invoices?page=1&limit=20&status=imported&provider=fortnox&sort=-created_at
Authorization: Bearer <token>
```
**查询参数:**
| 参数 | 类型 | 说明 |
|------|------|------|
| page | int | 页码,默认 1 |
| limit | int | 每页数量,默认 20最大 100 |
| status | string | 过滤状态 |
| provider | string | 过滤会计系统 |
| sort | string | 排序字段,`-` 前缀表示降序 |
**响应:**
```json
{
"success": true,
"data": {
"items": [
{
"id": "inv_uuid",
"status": "imported",
"provider": "fortnox",
"file_name": "Invoice_2024_001.pdf",
"supplier_name": "ABC Company",
"amount_total": 1250.00,
"invoice_date": "2024-01-15",
"voucher": {
"series": "A",
"number": "1234",
"url": "https://api.fortnox.se/3/vouchers/A/1234"
},
"created_at": "2026-02-03T10:30:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 156,
"total_pages": 8
}
}
}
```
### 4.3 获取发票详情
```http
GET /invoices/{id}
Authorization: Bearer <token>
```
### 4.4 更新发票数据 (审核时)
```http
PATCH /invoices/{id}
Authorization: Bearer <token>
Content-Type: application/json
{
"extraction": {
"supplier_name": "Corrected Name",
"supplier_org_number": "556677-8899",
"amount_total": 1300.00,
"vat_rate": 25
},
"voucher_rows": [
{
"account": 6210,
"debit": 1040.00,
"credit": 0
},
{
"account": 2610,
"debit": 260.00,
"credit": 0
},
{
"account": 2440,
"debit": 0,
"credit": 1300.00
}
]
}
```
### 4.5 导入到会计系统
```http
POST /invoices/{id}/import
Authorization: Bearer <token>
Content-Type: application/json
{
"provider": "fortnox",
"create_supplier": false,
"supplier_data": {
"name": "New Supplier",
"organisation_number": "112233-4455"
}
}
```
**响应:**
```json
{
"success": true,
"data": {
"id": "inv_uuid",
"status": "imported",
"provider": "fortnox",
"voucher": {
"series": "A",
"number": "1234",
"url": "https://api.fortnox.se/3/vouchers/A/1234"
},
"supplier": {
"number": "123",
"name": "ABC Company"
},
"attachment": {
"id": "att_xxx",
"uploaded": true
},
"accounting_url": "https://apps.fortnox.se/...",
"imported_at": "2026-02-03T10:35:00Z"
}
}
```
### 4.6 删除发票
```http
DELETE /invoices/{id}
Authorization: Bearer <token>
```
仅允许删除未导入 (`pending`, `preview`, `failed`) 状态的发票。
---
## 5. 供应商管理 (通用接口)
### 5.1 获取供应商列表
```http
GET /accounting/{provider}/suppliers?search=ABC&page=1&limit=50
Authorization: Bearer <token>
```
**响应:**
```json
{
"success": true,
"data": {
"items": [
{
"supplier_number": "123",
"name": "ABC Company",
"organisation_number": "556677-8899",
"address": "Storgatan 1, 123 45 Stockholm",
"phone": "08-123 45 67",
"email": "info@abc.com",
"bankgiro": "123-4567",
"cached_at": "2026-02-03T09:00:00Z"
}
],
"pagination": {
"page": 1,
"limit": 50,
"total": 45
},
"from_cache": true
}
}
```
### 5.2 创建供应商
```http
POST /accounting/{provider}/suppliers
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "New Supplier AB",
"organisation_number": "112233-4455",
"address1": "Testgatan 1",
"postcode": "123 45",
"city": "Stockholm",
"phone": "08-123 45 67",
"email": "info@supplier.com",
"bankgiro": "765-4321"
}
```
### 5.3 刷新供应商缓存
```http
POST /accounting/{provider}/suppliers/refresh-cache
Authorization: Bearer <token>
```
---
## 6. 会计科目 (通用接口)
### 6.1 获取科目列表
```http
GET /accounting/{provider}/accounts
Authorization: Bearer <token>
```
**响应:**
```json
{
"success": true,
"data": {
"accounts": [
{
"code": 2440,
"name": "Leverantörsskulder",
"type": "liability"
},
{
"code": 2610,
"name": "Ingående moms",
"type": "liability"
},
{
"code": 5460,
"name": "Kontorsmaterial",
"type": "expense"
}
]
}
}
```
### 6.2 获取科目映射规则
```http
GET /account-mappings?provider=fortnox
Authorization: Bearer <token>
```
### 6.3 创建科目映射规则
```http
POST /account-mappings
Authorization: Bearer <token>
Content-Type: application/json
{
"provider": "fortnox",
"supplier_org_number": "556677-8899",
"keyword": "kontor",
"account_code": 5460,
"vat_rate": 25,
"description_template": "{supplier_name} - Kontorsmaterial",
"priority": 5
}
```
---
## 7. Webhooks
### 7.1 通用 Webhook 接收
```http
POST /webhooks/{provider}
Headers:
X-Provider-Event: voucher.created
X-Provider-Signature: sha256=...
{
"event": "voucher.created",
"provider": "fortnox",
"timestamp": "2026-02-03T10:30:00Z",
"data": {
"voucher_number": "1234",
"series": "A",
"company_org_number": "556677-8899"
}
}
```
### 7.2 注册 Webhook (内部)
```http
POST /webhooks/register
Authorization: Bearer <admin_token>
Content-Type: application/json
{
"provider": "fortnox",
"url": "https://api.invoice-master.app/webhooks/fortnox",
"events": ["voucher.created", "voucher.updated"]
}
```
---
## 8. 健康检查
### 8.1 基础健康检查
```http
GET /health
```
**响应:**
```json
{
"status": "healthy",
"timestamp": "2026-02-03T10:30:00Z",
"version": "2.0.0"
}
```
### 8.2 详细健康检查
```http
GET /health/detailed
Authorization: Bearer <admin_token>
```
**响应:**
```json
{
"status": "healthy",
"timestamp": "2026-02-03T10:30:00Z",
"checks": {
"database": { "status": "healthy", "latency_ms": 5 },
"redis": { "status": "healthy", "latency_ms": 2 },
"providers": {
"fortnox": { "status": "healthy", "latency_ms": 150 },
"visma": { "status": "not_configured" }
},
"ocr_api": { "status": "healthy", "latency_ms": 50 },
"blob_storage": { "status": "healthy", "latency_ms": 30 }
}
}
```
---
## 9. 错误代码表
| 错误代码 | HTTP 状态 | 说明 |
|----------|-----------|------|
| `UNAUTHORIZED` | 401 | Token 无效或过期 |
| `FORBIDDEN` | 403 | 无权限访问 |
| `NOT_FOUND` | 404 | 资源不存在 |
| `VALIDATION_ERROR` | 400 | 请求参数验证失败 |
| `PROVIDER_NOT_SUPPORTED` | 400 | 不支持的会计系统 |
| `PROVIDER_NOT_CONNECTED` | 422 | 用户未连接该会计系统 |
| `PROVIDER_TOKEN_EXPIRED` | 401 | 会计系统 Token 过期 |
| `PROVIDER_RATE_LIMITED` | 429 | 会计系统 API 限流 |
| `OCR_FAILED` | 422 | OCR 提取失败 |
| `INVOICE_ALREADY_IMPORTED` | 409 | 发票已导入 |
| `INVALID_FILE_TYPE` | 400 | 不支持的文件类型 |
| `FILE_TOO_LARGE` | 400 | 文件超过大小限制 |
| `SUPPLIER_NOT_FOUND` | 404 | 供应商不存在 |
| `VOUCHER_CREATE_FAILED` | 422 | 凭证创建失败 |
---
## 10. 速率限制
| 端点 | 限制 |
|------|------|
| `/auth/*` | 10 req/min |
| `/invoices` (POST) | 10 req/min |
| `/invoices/*` | 100 req/min |
| `/accounting/{provider}/*` | 30 req/min |
| 其他 | 100 req/min |
限速响应头:
```http
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1643875200
```
---
## 11. OpenAPI 规范
完整的 OpenAPI 3.0 规范可在 `/docs` 端点查看 (Swagger UI)。
```http
GET /docs # Swagger UI
GET /openapi.json # OpenAPI JSON
```
---
## 12. API 变更日志
### v3.0 (2026-02-03)
**技术栈变更:**
- Python/FastAPI → .NET 8 + ASP.NET Core
- 新增 CQRS 模式 (MediatR)
- 新增领域事件 (审计支持)
- 新增审计 API 端点
**新增:**
- `/invoices/{id}/audit-log` - 获取发票审计日志
- `/users/me/audit-log` - 获取用户操作历史
- `/admin/audit-export` - 导出审计报告
### v2.0 (2026-02-03)
**新增:**
- 多会计系统支持
- `/accounting/providers` - 获取支持的会计系统列表
- `/accounting/{provider}/auth/url` - 通用授权 URL 接口
- `/accounting/{provider}/auth/callback` - 通用 OAuth 回调
- `/accounting/connections` - 获取所有连接
- `/accounting/connections/{provider}` - 特定连接管理
**变更:**
- `/fortnox/*` 接口迁移到 `/accounting/{provider}/*`
- `/suppliers` 迁移到 `/accounting/{provider}/suppliers`
- `/accounts` 迁移到 `/accounting/{provider}/accounts`
- 发票上传增加 `provider` 参数
- 发票导入增加 `provider` 参数
**废弃:**
- `/fortnox/*` (旧接口,将在 v3.0 移除)
---
**文档历史:**
| 版本 | 日期 | 作者 | 变更 |
|------|------|------|------|
| 3.0 | 2026-02-03 | Claude Code | 重构为 .NET + CQRS + 审计支持 |
| 2.0 | 2026-02-03 | Claude Code | 添加多会计系统支持 |
| 1.0 | 2026-02-03 | Claude Code | 初始版本 |

505
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,505 @@
# Invoice Master - 系统架构文档
**版本**: v3.0
**日期**: 2026-02-03
**状态**: 设计中
---
## 1. 项目概述
### 1.1 目标
构建一个**多会计系统集成的发票处理平台**,允许企业通过 Invoice Master OCR 技术自动识别发票并导入到各种会计软件。首个支持 Fortnox架构设计支持未来无缝集成其他会计系统如 Visma, Hogia 等)。
### 1.2 核心功能
1. **多会计系统支持** - 统一的抽象层,支持连接不同的会计软件
2. **OAuth2 认证** - 安全连接用户会计系统账户
3. **发票 OCR 识别** - 调用现有 invoice-master API 进行发票字段提取
4. **供应商自动匹配** - 智能匹配或创建会计系统供应商
5. **会计凭证生成** - 自动生成会计凭证
6. **文件存档** - 上传发票 PDF 到会计系统
7. **审计追踪** - 完整的操作日志和事件溯源
### 1.3 集成模式
采用 **外部独立应用 (External App)** 模式,支持多提供商:
```
┌─────────────────┐ ┌─────────────────────────┐ ┌─────────────────┐
│ Fortnox │────▶│ │────▶│ Fortnox │
│ (点击集成) │ │ │ │ (数据已导入) │
└─────────────────┘ │ Invoice Master │ └─────────────────┘
│ (多会计系统集成平台) │
┌─────────────────┐ │ │ ┌─────────────────┐
│ Visma │────▶│ - Fortnox Provider │────▶│ Visma │
│ (点击集成) │ │ - Visma Provider │ │ (数据已导入) │
└─────────────────┘ │ - Hogia Provider │ └─────────────────┘
│ - ... │
┌─────────────────┐ │ │ ┌─────────────────┐
│ Hogia │────▶│ │────▶│ Hogia │
│ (点击集成) │ │ │ │ (数据已导入) │
└─────────────────┘ └─────────────────────────┘ └─────────────────┘
```
---
## 2. 系统架构
### 2.1 整体架构(.NET + 轻量级 DDD
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 用户层 (User Layer) │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ React Frontend │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ 登录/授权 │ │ 发票上传 │ │ 结果确认 │ │ 历史记录 │ │ │
│ │ │ 页面 │ │ 页面 │ │ 页面 │ │ 页面 │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│ HTTPS / REST API
┌─────────────────────────────────────────────────────────────────────────────┐
│ 应用层 (Application Layer) │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ ASP.NET Core Web API │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Controllers│ │ Minimal │ │ Filters │ │ Middleware│ │ │
│ │ │ │ │ APIs │ │ │ │ │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │ │ │
│ │ └────────────────┼──────────────────────────────────────────┘ │
│ │ │ │
│ │ ┌───────────────────────┴──────────────────────────────────────────┐ │ │
│ │ │ Application Layer (CQRS Lite) │ │ │
│ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │
│ │ │ │ Commands │ │ Queries │ │ DTOs │ │ │ │
│ │ │ │ Handlers │ │ Handlers │ │ Mappers │ │ │ │
│ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │
│ │ └──────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Domain Layer (DDD Lite) │ │ │
│ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │
│ │ │ │ Aggregates │ │ Domain │ │ Domain │ │ │ │
│ │ │ │ (Invoice) │ │ Events │ │ Services │ │ │ │
│ │ │ │ (Connection)│ │ │ │ │ │ │ │
│ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────────────────────────────────────────────────────┐│ │ │
│ │ │ │ Accounting System Integration Layer ││ │ │
│ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││ │ │
│ │ │ │ │ Abstract │ │ Fortnox │ │ Visma │ ... ││ │ │
│ │ │ │ │ Interface │──│ Provider │ │ Provider │ ││ │ │
│ │ │ │ │ │ │ │ │ │ ││ │ │
│ │ │ │ │ - Supplier │ │ - OAuth2 │ │ - OAuth2 │ ││ │ │
│ │ │ │ │ - Voucher │ │ - REST API │ │ - REST API │ ││ │ │
│ │ │ │ │ - Account │ │ - Webhooks │ │ - Webhooks │ ││ │ │
│ │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ ││ │ │
│ │ │ │ ││ │ │
│ │ │ │ Factory: IAccountingSystemFactory.Create("fortnox") ││ │ │
│ │ │ └─────────────────────────────────────────────────────────────┘│ │ │
│ │ └──────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Infrastructure Layer │ │ │
│ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │
│ │ │ │ EF Core │ │ Redis │ │ Blob │ │ │ │
│ │ │ │ Repository │ │ Cache │ │ Storage │ │ │ │
│ │ │ │ EventStore│ │ │ │ │ │ │ │
│ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │
│ │ └──────────────────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│ HTTP API
┌─────────────────────────────────────────────────────────────────────────────┐
│ 外部服务层 (External Services) │
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Invoice Master │ │ Fortnox │ │ Visma │ │
│ │ OCR API │ │ Platform │ │ Platform │ │
│ │ │ │ │ │ │ │
│ │ - /api/v1/infer │ │ - OAuth2 Server │ │ - OAuth2 Server │ │
│ │ - YOLO + PaddleOCR │ │ - REST API │ │ - REST API │ │
│ │ - 94.8% accuracy │ │ - Supplier/Voucher │ │ - Supplier/Voucher │ │
│ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│ SQL / Redis
┌─────────────────────────────────────────────────────────────────────────────┐
│ 数据层 (Data Layer) │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ PostgreSQL │ │ Redis │ │ Azure Blob Storage │ │
│ │ │ │ │ │ │ │
│ │ - Users │ │ - Token Cache │ │ - Invoice PDFs │ │
│ │ - Connections │ │ - Rate Limiting │ │ - Temporary Files │ │
│ │ - Invoices │ │ - Session Store │ │ │ │
│ │ - DomainEvents │ │ │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 2.2 分层架构详解
```
┌─────────────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ (Controllers / Minimal APIs) │
│ │
│ - Input validation (FluentValidation) │
│ - Authentication / Authorization │
│ - Response mapping │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Application Layer (CQRS Lite) │
│ │
│ Commands (Write): Queries (Read): │
│ - ImportInvoiceCommand - GetInvoiceQuery │
│ - CreateConnectionCommand - ListInvoicesQuery │
│ - UpdateSupplierCommand - GetConnectionQuery │
│ │
│ - MediatR for dispatching │
│ - AutoMapper for DTO mapping │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Domain Layer (DDD Lite) │
│ │
│ Aggregates: │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Invoice │ │ AccountingConnection│ SupplierCache │ │
│ │ │ │ │ │ │ │
│ │ - Submit() │ │ - Connect() │ │ - Update() │ │
│ │ - Import() │ │ - Disconnect() │ │ - Expire() │ │
│ │ - Reject() │ │ - RefreshToken()│ │ │ │
│ │ │ │ │ │ │ │
│ │ Domain Events: │ │ Domain Events: │ │ │ │
│ │ - InvoiceSubmitted│ - Connected │ │ │ │
│ │ - InvoiceImported │ - TokenRefreshed│ │ │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
│ Domain Services: │
│ - InvoiceProcessingService │
│ - SupplierMatchingService │
│ - VoucherGenerationService │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ EF Core │ │ Event Store │ │ External APIs │ │
│ │ Repositories │ │ (Audit Table) │ │ │ │
│ │ │ │ │ │ - FortnoxClient │ │
│ │ - IRepository │ │ - Append events │ │ - VismaClient │ │
│ │ - Unit of Work │ │ - Replay events │ │ - OCRClient │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
### 2.3 会计系统集成层详解
```
┌─────────────────────────────────────────────────────────────────────┐
│ Accounting System Integration │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Abstract Interface │ │
│ │ (InvoiceMaster.Integrations/Accounting/IAccountingSystem.cs)│ │
│ │ │ │
│ │ public interface IAccountingSystem │ │
│ │ { │ │
│ │ string ProviderName { get; } │ │
│ │ Task<AuthResult> AuthenticateAsync(string code); │ │
│ │ Task<List<Supplier>> GetSuppliersAsync(); │ │
│ │ Task<Voucher> CreateVoucherAsync(Voucher voucher); │ │
│ │ // ... │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ │ implements │
│ ┌───────────────────┼───────────────────┐ │
│ │ │ │ │
│ ┌───────▼──────┐ ┌────────▼────────┐ ┌──────▼───────┐ │
│ │ Fortnox │ │ Visma │ │ Hogia │ ... │
│ │ Provider │ │ Provider │ │ Provider │ │
│ │ │ │ │ │ │ │
│ │ - OAuth2 │ │ - OAuth2 │ │ - OAuth2 │ │
│ │ - Swedish │ │ - Nordic APIs │ │ - Swedish │ │
│ │ - BAS 2024 │ │ - Localized │ │ - Custom │ │
│ └──────────────┘ └─────────────────┘ └──────────────┘ │
│ │
│ Factory: IAccountingSystemFactory.Create(providerName: string) │
│ Registry: services.AddAccountingSystem<FortnoxProvider>("fortnox") │
└─────────────────────────────────────────────────────────────────────┘
```
### 2.4 领域事件与审计
```
┌─────────────────────────────────────────────────────────────────────┐
│ Domain Event Flow │
│ │
│ 1. Domain Action │
│ Invoice.Import() called │
│ │ │
│ ▼ │
│ 2. Event Raised │
│ _domainEvents.Add(new InvoiceImportedEvent { ... }) │
│ │ │
│ ▼ │
│ 3. Transaction Commit │
│ EF Core SaveChangesAsync() │
│ │ │
│ ▼ │
│ 4. Event Dispatcher │
│ DispatchDomainEventsInterceptor │
│ │ │
│ ├──────────────────┬──────────────────┐ │
│ ▼ ▼ ▼ │
│ 5. Handlers: Save to DB Send Notification │
│ - AuditLogHandler DomainEvents table - SignalR │
│ - NotificationHandler - Webhook │
│ - IntegrationHandler │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 3. 技术栈选型
### 3.1 后端技术栈 (.NET 8)
| 技术 | 版本 | 用途 |
|------|------|------|
| .NET | 8.0 | 运行时和框架 |
| ASP.NET Core | 8.0 | Web API |
| Entity Framework Core | 8.0 | ORM |
| MediatR | 12.x | CQRS / 中介者模式 |
| AutoMapper | 12.x | 对象映射 |
| FluentValidation | 11.x | 输入验证 |
| FluentAssertions | 6.x | 测试断言 |
| xUnit | 2.x | 测试框架 |
| Moq | 4.x | Mocking |
| Serilog | 3.x | 结构化日志 |
| Polly | 8.x | 重试和熔断 |
| JWT Bearer | 8.x | 认证 |
| Swagger/OpenAPI | 6.x | API 文档 |
### 3.2 审计与事件存储
```csharp
// 领域事件基类
public abstract class DomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTime OccurredAt { get; } = DateTime.UtcNow;
public string EventType { get; protected set; }
public string AggregateType { get; set; }
public Guid AggregateId { get; set; }
public string UserId { get; set; }
public string CorrelationId { get; set; }
}
// 具体事件
public class InvoiceImportedEvent : DomainEvent
{
public string Provider { get; set; }
public string VoucherNumber { get; set; }
public decimal Amount { get; set; }
public InvoiceImportedEvent()
{
EventType = nameof(InvoiceImportedEvent);
}
}
```
### 3.3 会计系统集成使用示例
```csharp
// 使用示例
public class ImportInvoiceCommandHandler : IRequestHandler<ImportInvoiceCommand, Result<InvoiceDto>>
{
private readonly IAccountingSystemFactory _factory;
private readonly IInvoiceRepository _invoiceRepository;
public async Task<Result<InvoiceDto>> Handle(
ImportInvoiceCommand request,
CancellationToken cancellationToken)
{
// 创建 Fortnox 连接
var accounting = _factory.Create("fortnox", connection.AccessToken);
// 创建 Visma 连接(未来)
// var accounting = _factory.Create("visma", connection.AccessToken);
// 统一接口,无需关心底层实现
var invoice = await _invoiceRepository.GetByIdAsync(request.InvoiceId);
// 执行业务逻辑
invoice.Import(request.Provider, request.UserId);
await _invoiceRepository.SaveChangesAsync();
// 领域事件自动保存到审计表
return Result.Success(_mapper.Map<InvoiceDto>(invoice));
}
}
```
---
## 4. 数据模型变更
### 4.1 数据库表结构
**核心表:**
- `Users` - 用户表
- `AccountingConnections` - 通用会计系统连接表
- `Invoices` - 通用发票表
- `SupplierCaches` - 通用供应商缓存表
- `DomainEvents` - 领域事件表(审计)
```sql
-- 领域事件表(审计)
CREATE TABLE DomainEvents (
Id UUID PRIMARY KEY,
EventType VARCHAR(255) NOT NULL,
AggregateType VARCHAR(255) NOT NULL,
AggregateId UUID NOT NULL,
OccurredAt TIMESTAMP WITH TIME ZONE NOT NULL,
UserId VARCHAR(255),
CorrelationId VARCHAR(255),
Payload JSONB NOT NULL,
Processed BOOLEAN DEFAULT FALSE
);
CREATE INDEX IX_DomainEvents_Aggregate ON DomainEvents(AggregateType, AggregateId);
CREATE INDEX IX_DomainEvents_OccurredAt ON DomainEvents(OccurredAt DESC);
```
---
## 5. 扩展性设计
### 5.1 添加新会计系统的步骤
1. **创建 Provider 类**
```csharp
// InvoiceMaster.Integrations/Accounting/Providers/VismaProvider.cs
public class VismaProvider : IAccountingSystem
{
public string ProviderName => "visma";
public async Task<AuthResult> AuthenticateAsync(string code)
{
// Visma OAuth2 实现
}
public async Task<List<Supplier>> GetSuppliersAsync()
{
// Visma API 实现
}
// ... 其他方法实现
}
```
2. **注册到 DI 容器**
```csharp
// Program.cs
builder.Services.AddAccountingSystem<VismaProvider>("visma");
```
3. **添加配置**
```json
{
"Visma": {
"ClientId": "xxx",
"ClientSecret": "xxx",
"RedirectUri": "..."
}
}
```
4. **完成** - 无需修改业务逻辑代码
### 5.2 未来支持的会计系统
| 会计系统 | 市场 | 优先级 | 预计工作量 |
|----------|------|--------|-----------|
| **Fortnox** | 瑞典 | P0 | 已完成 |
| **Visma eAccounting** | 北欧 | P1 | 2-3 周 |
| **Hogia Smart** | 瑞典 | P2 | 2-3 周 |
| **BjornLunden** | 瑞典 | P2 | 2-3 周 |
| **Sage** | 欧洲 | P3 | 3-4 周 |
| **QuickBooks** | 全球 | P3 | 3-4 周 |
---
## 6. API 设计变更
### 6.1 会计系统连接 API
```http
# 获取支持的会计系统列表
GET /api/v1/accounting/providers
# 获取特定会计系统的授权 URL
GET /api/v1/accounting/{provider}/auth/url
# OAuth 回调(通用)
GET /api/v1/accounting/{provider}/auth/callback?code=xxx&state=xxx
# 获取用户的所有连接
GET /api/v1/accounting/connections
# 断开特定会计系统连接
DELETE /api/v1/accounting/connections/{provider}
```
### 6.2 审计 API
```http
# 获取发票的审计日志
GET /api/v1/invoices/{id}/audit-log
# 获取用户的操作历史
GET /api/v1/users/me/audit-log
# 导出审计报告(管理员)
GET /api/v1/admin/audit-export?from=2026-01-01&to=2026-02-01
```
---
## 7. 相关文档
- [API 设计文档](./API_DESIGN.md)
- [数据库 Schema](./DATABASE_SCHEMA.md)
- [开发计划](./DEVELOPMENT_PLAN.md)
- [部署指南](./DEPLOYMENT_GUIDE.md)
- [目录结构](./DIRECTORY_STRUCTURE.md)
---
**文档历史:**
| 版本 | 日期 | 作者 | 变更 |
|------|------|------|------|
| 3.0 | 2026-02-03 | Claude Code | 重构为 .NET + 轻量级 DDD |
| 2.0 | 2026-02-03 | Claude Code | 重构为多会计系统架构 |
| 1.0 | 2026-02-03 | Claude Code | 初始版本(仅 Fortnox |

879
DATABASE_SCHEMA.md Normal file
View File

@@ -0,0 +1,879 @@
# Invoice Master - 数据库 Schema 设计
**版本**: v3.0
**数据库**: PostgreSQL 15+
**ORM**: Entity Framework Core 8
**日期**: 2026-02-03
---
## 1. ER 图 (多会计系统版)
```
┌──────────────────┐ ┌──────────────────────────┐ ┌──────────────────┐
│ users │ │ accounting_connections │ │ supplier_cache │
├──────────────────┤ ├──────────────────────────┤ ├──────────────────┤
│ id (PK) │◄──────┤ id (PK) │◄──────┤ id (PK) │
│ email │ │ user_id (FK) │ │ connection_id(FK)│
│ hashed_password │ │ provider (e.g. 'fortnox')│ │ supplier_number │
│ is_active │ │ access_token_encrypted │ │ name │
│ created_at │ │ refresh_token_encrypted │ │ org_number │
└──────────────────┘ │ company_name │ │ cached_at │
│ company_org_number │ └──────────────────┘
│ UNIQUE(user_id, provider)│
└───────────┬──────────────┘
┌───────────▼───────────┐
│ invoices │
├───────────────────────┤
│ id (PK) │
│ connection_id (FK) │◄──────────────────────┐
│ provider │ │
│ file_path │ │
│ extraction_data │ │
│ supplier_number │ │
│ voucher_number │ │
│ status │ │
└───────────┬───────────┘ │
│ │
│ │
┌───────────▼───────────┐ ┌───────────────▼──┐
│ invoice_reviews │ │ processing_queue │
├───────────────────────┤ ├──────────────────┤
│ id (PK) │ │ id (PK) │
│ invoice_id (FK) │ │ invoice_id (FK) │
│ reviewed_by │ │ status │
│ corrections │ │ retry_count │
│ approved │ │ error_message │
└───────────────────────┘ └──────────────────┘
```
---
## 2. 表结构定义
### 2.1 users - 用户表
存储系统用户基本信息。
```sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
hashed_password VARCHAR(255) NOT NULL,
full_name VARCHAR(255),
is_active BOOLEAN DEFAULT true,
is_superuser BOOLEAN DEFAULT false,
-- 时间戳
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_login_at TIMESTAMP WITH TIME ZONE
);
-- 索引
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_active ON users(is_active);
```
### 2.2 accounting_connections - 会计系统连接表
存储多个会计系统的 OAuth 连接信息,一个用户可以连接多个会计系统。
```sql
CREATE TABLE accounting_connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 会计系统标识
provider VARCHAR(50) NOT NULL, -- 'fortnox', 'visma', 'hogia', etc.
-- OAuth Tokens (加密存储)
access_token_encrypted TEXT NOT NULL,
refresh_token_encrypted TEXT NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
scope TEXT, -- 权限范围,不同 provider 格式不同
-- 公司信息
company_name VARCHAR(255),
company_org_number VARCHAR(20),
-- 默认设置 (各 provider 可能有不同的默认值)
default_voucher_series VARCHAR(10) DEFAULT 'A',
default_account_code INTEGER DEFAULT 5460,
auto_attach_pdf BOOLEAN DEFAULT true,
auto_create_supplier BOOLEAN DEFAULT false,
-- 状态
is_active BOOLEAN DEFAULT true,
last_sync_at TIMESTAMP WITH TIME ZONE,
-- 时间戳
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- 一个用户可连接多个会计系统,但不能重复连接同一个
UNIQUE(user_id, provider)
);
-- 索引
CREATE INDEX idx_connections_user ON accounting_connections(user_id);
CREATE INDEX idx_connections_provider ON accounting_connections(provider);
CREATE INDEX idx_connections_active ON accounting_connections(is_active);
CREATE INDEX idx_connections_expires ON accounting_connections(expires_at);
```
### 2.3 invoices - 发票处理记录表
存储发票处理历史和状态,支持多会计系统。
```sql
CREATE TYPE invoice_status AS ENUM (
'pending', -- 等待处理
'uploading', -- 正在上传
'processing', -- OCR 处理中
'preview', -- 等待用户确认
'importing', -- 正在导入会计系统
'imported', -- 已成功导入
'failed' -- 处理失败
);
CREATE TYPE supplier_match_action AS ENUM (
'USE_EXISTING', -- 使用现有供应商
'CREATE_NEW', -- 创建新供应商
'SUGGEST_MATCH' -- 建议匹配
);
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
connection_id UUID NOT NULL REFERENCES accounting_connections(id) ON DELETE CASCADE,
-- 冗余存储 provider便于查询
provider VARCHAR(50) NOT NULL,
-- 文件信息
original_filename VARCHAR(255) NOT NULL,
storage_path TEXT NOT NULL,
file_size INTEGER,
file_hash VARCHAR(64), -- SHA-256 用于去重
-- OCR 提取结果 (JSONB 存储结构化数据)
extraction_data JSONB,
extraction_confidence DECIMAL(3,2), -- 0.00 - 1.00
-- 提取的字段 (冗余存储便于查询)
extracted_supplier_name VARCHAR(255),
extracted_supplier_org_number VARCHAR(20),
extracted_invoice_number VARCHAR(100),
extracted_invoice_date DATE,
extracted_due_date DATE,
extracted_amount_total DECIMAL(12,2),
extracted_amount_vat DECIMAL(12,2),
extracted_vat_rate INTEGER,
extracted_ocr_number VARCHAR(50),
extracted_bankgiro VARCHAR(50),
extracted_plusgiro VARCHAR(50),
extracted_currency VARCHAR(3) DEFAULT 'SEK',
-- 供应商匹配结果
supplier_number VARCHAR(50),
supplier_match_confidence DECIMAL(3,2),
supplier_match_action supplier_match_action,
-- 会计凭证信息
voucher_series VARCHAR(10),
voucher_number VARCHAR(50),
voucher_url TEXT,
-- 会计科目分配 (JSONB 数组)
voucher_rows JSONB,
-- 处理状态
status invoice_status DEFAULT 'pending',
error_message TEXT,
error_code VARCHAR(50),
-- 用户审核
reviewed_by UUID REFERENCES users(id),
reviewed_at TIMESTAMP WITH TIME ZONE,
-- 附件信息
attachment_id VARCHAR(100),
attachment_url TEXT,
-- 时间戳
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
processed_at TIMESTAMP WITH TIME ZONE
);
-- 索引
CREATE INDEX idx_invoices_connection ON invoices(connection_id);
CREATE INDEX idx_invoices_provider ON invoices(provider);
CREATE INDEX idx_invoices_status ON invoices(status);
CREATE INDEX idx_invoices_created ON invoices(created_at DESC);
CREATE INDEX idx_invoices_file_hash ON invoices(file_hash);
CREATE INDEX idx_invoices_supplier ON invoices(extracted_supplier_org_number);
-- GIN 索引用于 JSONB 查询
CREATE INDEX idx_invoices_extraction ON invoices USING GIN (extraction_data);
```
### 2.4 supplier_cache - 供应商缓存表
缓存各会计系统的供应商列表,减少 API 调用。
```sql
CREATE TABLE supplier_cache (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
connection_id UUID NOT NULL REFERENCES accounting_connections(id) ON DELETE CASCADE,
-- 供应商信息 (各 provider 字段可能不同)
supplier_number VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
organisation_number VARCHAR(20),
address1 VARCHAR(255),
address2 VARCHAR(255),
postcode VARCHAR(20),
city VARCHAR(100),
country VARCHAR(100),
phone VARCHAR(50),
email VARCHAR(255),
bankgiro_number VARCHAR(50),
plusgiro_number VARCHAR(50),
-- 缓存元数据
cached_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + INTERVAL '1 hour',
UNIQUE(connection_id, supplier_number)
);
-- 索引
CREATE INDEX idx_supplier_cache_connection ON supplier_cache(connection_id);
CREATE INDEX idx_supplier_cache_org ON supplier_cache(organisation_number);
CREATE INDEX idx_supplier_cache_name ON supplier_cache(name);
CREATE INDEX idx_supplier_cache_expires ON supplier_cache(expires_at);
```
### 2.5 invoice_reviews - 发票审核记录表
存储用户对 OCR 结果的修改和确认。
```sql
CREATE TABLE invoice_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
-- 审核人
reviewed_by UUID NOT NULL REFERENCES users(id),
-- 用户修正的数据 (JSONB)
corrections JSONB,
-- 审核结果
approved BOOLEAN NOT NULL,
rejection_reason TEXT,
-- 时间戳
reviewed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 索引
CREATE INDEX idx_reviews_invoice ON invoice_reviews(invoice_id);
CREATE INDEX idx_reviews_user ON invoice_reviews(reviewed_by);
```
### 2.6 processing_queue - 处理队列表
异步任务队列,用于发票处理。
```sql
CREATE TYPE queue_status AS ENUM (
'queued',
'processing',
'completed',
'failed',
'cancelled'
);
CREATE TABLE processing_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
-- 任务类型
task_type VARCHAR(50) NOT NULL, -- 'ocr', 'import', 'upload_attachment'
-- 状态
status queue_status DEFAULT 'queued',
priority INTEGER DEFAULT 5, -- 1-10, 数字越小优先级越高
-- 重试机制
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
-- 调度
scheduled_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
-- 错误信息
error_message TEXT,
error_stack TEXT,
-- 处理结果
result_data JSONB,
-- 工作节点
worker_id VARCHAR(100)
);
-- 索引
CREATE INDEX idx_queue_status ON processing_queue(status);
CREATE INDEX idx_queue_scheduled ON processing_queue(scheduled_at);
CREATE INDEX idx_queue_invoice ON processing_queue(invoice_id);
CREATE INDEX idx_queue_priority ON processing_queue(status, priority, scheduled_at);
```
### 2.7 account_mappings - 科目映射表
存储用户自定义的会计科目映射规则,支持多会计系统。
```sql
CREATE TABLE account_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
connection_id UUID NOT NULL REFERENCES accounting_connections(id) ON DELETE CASCADE,
-- 会计系统标识
provider VARCHAR(50) NOT NULL,
-- 匹配规则
supplier_org_number VARCHAR(20), -- 特定供应商
keyword VARCHAR(100), -- 关键词匹配
-- 科目设置 (不同 provider 可能有不同的科目体系)
account_code INTEGER NOT NULL,
account_name VARCHAR(255),
vat_rate INTEGER DEFAULT 25,
-- 描述模板
description_template VARCHAR(255), -- e.g., "{supplier_name} - {invoice_number}"
-- 优先级 (数字越大优先级越高)
priority INTEGER DEFAULT 0,
-- 状态
is_active BOOLEAN DEFAULT true,
-- 时间戳
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- 约束: supplier_org_number 和 keyword 至少有一个
CONSTRAINT check_mapping_rule CHECK (
supplier_org_number IS NOT NULL OR keyword IS NOT NULL
)
);
-- 索引
CREATE INDEX idx_mappings_connection ON account_mappings(connection_id);
CREATE INDEX idx_mappings_provider ON account_mappings(provider);
CREATE INDEX idx_mappings_supplier ON account_mappings(supplier_org_number);
CREATE INDEX idx_mappings_keyword ON account_mappings(keyword);
```
### 2.8 api_logs - API 日志表
记录关键 API 调用用于审计和调试。
```sql
CREATE TABLE api_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- 请求信息
request_id VARCHAR(100) NOT NULL,
connection_id UUID REFERENCES accounting_connections(id),
user_id UUID REFERENCES users(id),
-- API 详情
provider VARCHAR(50), -- 'fortnox', 'visma', 'ocr', 'internal'
endpoint VARCHAR(255) NOT NULL,
method VARCHAR(10) NOT NULL,
-- 状态
status_code INTEGER,
is_success BOOLEAN,
-- 性能
request_started_at TIMESTAMP WITH TIME ZONE,
request_ended_at TIMESTAMP WITH TIME ZONE,
duration_ms INTEGER,
-- 错误
error_code VARCHAR(50),
error_message TEXT,
-- 元数据 (脱敏)
metadata JSONB,
-- 时间戳
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 索引
CREATE INDEX idx_logs_request ON api_logs(request_id);
CREATE INDEX idx_logs_connection ON api_logs(connection_id);
CREATE INDEX idx_logs_provider ON api_logs(provider);
CREATE INDEX idx_logs_created ON api_logs(created_at DESC);
```
---
## 3. 视图
### 3.1 invoice_summary - 发票摘要视图
```sql
CREATE VIEW invoice_summary AS
SELECT
i.id,
i.connection_id,
i.provider,
i.original_filename,
i.status,
i.extraction_confidence,
i.extracted_supplier_name,
i.extracted_invoice_number,
i.extracted_invoice_date,
i.extracted_amount_total,
i.extracted_currency,
i.supplier_number,
i.supplier_match_confidence,
i.voucher_number,
i.voucher_series,
i.created_at,
i.processed_at,
ac.company_name as connection_company,
CASE
WHEN i.status = 'imported' THEN true
ELSE false
END as is_imported
FROM invoices i
JOIN accounting_connections ac ON i.connection_id = ac.id;
```
### 3.2 connection_stats - 连接统计视图
```sql
CREATE VIEW connection_stats AS
SELECT
ac.id as connection_id,
ac.provider,
ac.company_name,
ac.is_active,
COUNT(i.id) as total_invoices,
COUNT(CASE WHEN i.status = 'imported' THEN 1 END) as imported_invoices,
COUNT(CASE WHEN i.status = 'failed' THEN 1 END) as failed_invoices,
AVG(i.extraction_confidence) as avg_confidence,
MAX(i.created_at) as last_invoice_at
FROM accounting_connections ac
LEFT JOIN invoices i ON ac.id = i.connection_id
GROUP BY ac.id, ac.provider, ac.company_name, ac.is_active;
```
### 3.3 user_connections_view - 用户连接视图
```sql
CREATE VIEW user_connections_view AS
SELECT
u.id as user_id,
u.email,
ac.id as connection_id,
ac.provider,
ac.company_name,
ac.company_org_number,
ac.is_active as connection_active,
ac.created_at as connected_at
FROM users u
LEFT JOIN accounting_connections ac ON u.id = ac.user_id;
```
---
## 4. 函数和触发器
### 4.1 自动更新时间戳
```sql
-- 创建更新函数
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- 应用到各表
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_connections_updated_at BEFORE UPDATE ON accounting_connections
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_invoices_updated_at BEFORE UPDATE ON invoices
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_mappings_updated_at BEFORE UPDATE ON account_mappings
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
```
### 4.2 发票状态变更日志
```sql
CREATE TABLE invoice_status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
old_status invoice_status,
new_status invoice_status NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
changed_by UUID REFERENCES users(id)
);
-- 触发器记录状态变更
CREATE OR REPLACE FUNCTION log_invoice_status_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status IS DISTINCT FROM NEW.status THEN
INSERT INTO invoice_status_history (invoice_id, old_status, new_status)
VALUES (NEW.id, OLD.status, NEW.status);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER invoice_status_change_trigger
AFTER UPDATE ON invoices
FOR EACH ROW
EXECUTE FUNCTION log_invoice_status_change();
```
### 4.3 自动设置 provider 字段
```sql
-- 当插入发票时,自动从 connection 获取 provider
CREATE OR REPLACE FUNCTION set_invoice_provider()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.provider IS NULL THEN
SELECT provider INTO NEW.provider
FROM accounting_connections
WHERE id = NEW.connection_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_invoice_provider_trigger
BEFORE INSERT ON invoices
FOR EACH ROW
EXECUTE FUNCTION set_invoice_provider();
```
---
## 5. 初始化数据
```sql
-- 默认会计科目映射 (BAS 2024 - Fortnox)
INSERT INTO account_mappings (connection_id, provider, account_code, account_name, vat_rate, description_template, priority)
SELECT
ac.id,
ac.provider,
code,
name,
vat_rate,
description_template,
priority
FROM accounting_connections ac
CROSS JOIN (VALUES
(2440, 'Leverantörsskulder', 0, 'Faktura {invoice_number}', 0),
(2610, 'Ingående moms', 25, 'Moms 25%', 0),
(2620, 'Ingående moms', 12, 'Moms 12%', 0),
(2630, 'Ingående moms', 6, 'Moms 6%', 0),
(5460, 'Kontorsmaterial', 25, 'Kontorsmaterial', 1),
(5710, 'Frakter', 25, 'Frakt', 1),
(6100, 'Övriga externa tjänster', 25, 'Övriga tjänster', 1),
(6210, 'Konsultarvoden', 25, 'Konsult', 2)
) AS defaults(code, name, vat_rate, description_template, priority)
WHERE ac.provider = 'fortnox';
```
---
## 6. 迁移脚本
### 6.1 从 v1.0 迁移到 v2.0
```sql
-- 迁移脚本: 从单 Fortnox 支持到多会计系统支持
-- 1. 创建新表 accounting_connections
CREATE TABLE accounting_connections (
-- ... (见 2.2 节定义)
);
-- 2. 迁移 fortnox_tenants 数据到 accounting_connections
INSERT INTO accounting_connections (
id, user_id, provider, access_token_encrypted, refresh_token_encrypted,
expires_at, scope, company_name, company_org_number,
default_voucher_series, default_account_code, auto_attach_pdf, auto_create_supplier,
is_active, last_sync_at, created_at, updated_at
)
SELECT
id, user_id, 'fortnox', access_token_encrypted, refresh_token_encrypted,
expires_at, scope, company_name, company_org_number,
default_voucher_series, default_account_code, auto_attach_pdf, auto_create_supplier,
is_active, last_sync_at, created_at, updated_at
FROM fortnox_tenants;
-- 3. 创建新表 invoices
CREATE TABLE invoices (
-- ... (见 2.3 节定义)
);
-- 4. 迁移 fortnox_invoices 数据到 invoices
INSERT INTO invoices (
id, connection_id, provider, original_filename, storage_path, file_size, file_hash,
extraction_data, extraction_confidence,
extracted_supplier_name, extracted_supplier_org_number, extracted_invoice_number,
extracted_invoice_date, extracted_due_date, extracted_amount_total, extracted_amount_vat,
extracted_vat_rate, extracted_ocr_number, extracted_bankgiro, extracted_plusgiro, extracted_currency,
supplier_number, supplier_match_confidence, supplier_match_action,
voucher_series, voucher_number, voucher_url, voucher_rows,
status, error_message, error_code,
reviewed_by, reviewed_at,
attachment_id, attachment_url,
created_at, updated_at, processed_at
)
SELECT
id, tenant_id, 'fortnox', original_filename, storage_path, file_size, file_hash,
extraction_data, extraction_confidence,
extracted_supplier_name, extracted_supplier_org_number, extracted_invoice_number,
extracted_invoice_date, extracted_due_date, extracted_amount_total, extracted_amount_vat,
extracted_vat_rate, extracted_ocr_number, extracted_bankgiro, extracted_plusgiro, extracted_currency,
supplier_number, supplier_match_confidence, supplier_match_action,
voucher_series, voucher_number, voucher_url, voucher_rows,
status, error_message, error_code,
reviewed_by, reviewed_at,
attachment_id, attachment_url,
created_at, updated_at, processed_at
FROM fortnox_invoices;
-- 5. 更新外键引用
-- 更新 supplier_cache
ALTER TABLE supplier_cache DROP CONSTRAINT supplier_cache_tenant_id_fkey;
ALTER TABLE supplier_cache RENAME COLUMN tenant_id TO connection_id;
ALTER TABLE supplier_cache ADD CONSTRAINT supplier_cache_connection_id_fkey
FOREIGN KEY (connection_id) REFERENCES accounting_connections(id) ON DELETE CASCADE;
-- 6. 删除旧表 (在确认迁移成功后)
-- DROP TABLE fortnox_invoices;
-- DROP TABLE fortnox_tenants;
-- 7. 创建新索引和视图
-- ... (见相应章节)
```
### 6.2 初始迁移 (新部署)
```sql
-- 启用 UUID 扩展
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- 创建枚举类型
CREATE TYPE invoice_status AS ENUM ('pending', 'uploading', 'processing', 'preview', 'importing', 'imported', 'failed');
CREATE TYPE supplier_match_action AS ENUM ('USE_EXISTING', 'CREATE_NEW', 'SUGGEST_MATCH');
CREATE TYPE queue_status AS ENUM ('queued', 'processing', 'completed', 'failed', 'cancelled');
-- 创建表 (按依赖顺序)
-- 1. users
-- 2. accounting_connections
-- 3. invoices
-- 4. supplier_cache
-- 5. invoice_reviews
-- 6. processing_queue
-- 7. account_mappings
-- 8. api_logs
-- 9. invoice_status_history
-- 创建索引
-- 创建视图
-- 创建函数和触发器
```
---
## 7. 性能优化建议
### 7.1 分区策略
对于 `invoices``api_logs` 表,建议按时间分区:
```sql
-- 按月分区 (示例)
CREATE TABLE invoices_partitioned (
LIKE invoices INCLUDING ALL
) PARTITION BY RANGE (created_at);
-- 创建分区
CREATE TABLE invoices_2024_01 PARTITION OF invoices_partitioned
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE invoices_2024_02 PARTITION OF invoices_partitioned
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
```
### 7.2 多会计系统查询优化
```sql
-- 为常用查询创建复合索引
CREATE INDEX idx_invoices_provider_status ON invoices(provider, status);
CREATE INDEX idx_invoices_connection_created ON invoices(connection_id, created_at DESC);
-- 为 provider 统计查询优化
CREATE INDEX idx_connections_user_provider ON accounting_connections(user_id, provider);
```
### 7.3 清理策略
```sql
-- 定期清理过期缓存
DELETE FROM supplier_cache WHERE expires_at < NOW();
-- 归档旧日志
-- 建议将 90 天前的 api_logs 迁移到冷存储
CREATE TABLE api_logs_archive (LIKE api_logs INCLUDING ALL);
-- 归档脚本
INSERT INTO api_logs_archive
SELECT * FROM api_logs
WHERE created_at < NOW() - INTERVAL '90 days';
DELETE FROM api_logs
WHERE created_at < NOW() - INTERVAL '90 days';
```
---
## 8. 备份策略
| 数据类型 | 备份频率 | 保留期 |
|----------|----------|--------|
| 全量备份 | 每日 | 30 天 |
| 增量备份 | 每小时 | 7 天 |
| 事务日志 | 持续 | 7 天 |
```sql
-- 关键表逻辑备份示例
pg_dump --table=accounting_connections --table=invoices --table=users > backup.sql
-- 按 provider 备份
pg_dump --table=invoices --where="provider = 'fortnox'" > fortnox_invoices_backup.sql
```
---
## 9. EF Core 迁移
### 9.1 创建迁移
```bash
# 添加迁移
cd backend/src/InvoiceMaster.Infrastructure
dotnet ef migrations add InitialCreate --startup-project ../InvoiceMaster.API
# 更新数据库
dotnet ef database update --startup-project ../InvoiceMaster.API
# 生成 SQL 脚本
dotnet ef migrations script --startup-project ../InvoiceMaster.API -o migration.sql
```
### 9.2 领域事件存储配置
```csharp
// InvoiceMaster.Infrastructure/Data/Configurations/DomainEventConfiguration.cs
public class DomainEventConfiguration : IEntityTypeConfiguration<DomainEvent>
{
public void Configure(EntityTypeBuilder<DomainEvent> builder)
{
builder.ToTable("DomainEvents");
builder.HasKey(e => e.Id);
builder.Property(e => e.EventType)
.IsRequired()
.HasMaxLength(255);
builder.Property(e => e.AggregateType)
.IsRequired()
.HasMaxLength(255);
builder.Property(e => e.Payload)
.IsRequired()
.HasColumnType("jsonb");
builder.HasIndex(e => new { e.AggregateType, e.AggregateId })
.HasDatabaseName("IX_DomainEvents_Aggregate");
builder.HasIndex(e => e.OccurredAt)
.HasDatabaseName("IX_DomainEvents_OccurredAt");
}
}
```
## 10. Schema 变更日志
### v3.0 (2026-02-03)
**技术栈变更:**
- Python/SQLAlchemy → .NET 8/EF Core 8
- 新增 `DomainEvents` 表用于审计
**新增表:**
- `DomainEvents` - 领域事件存储(审计)
**EF Core 配置:**
- 所有表使用 Fluent API 配置
- 启用全局查询过滤器(软删除)
- 配置领域事件拦截器
### v2.0 (2026-02-03)
**新增表:**
- `accounting_connections` - 替换 `fortnox_tenants`,支持多会计系统
- `invoices` - 替换 `fortnox_invoices`,添加 `provider` 字段
**修改表:**
- `supplier_cache`: `tenant_id``connection_id`
- `account_mappings`: 添加 `connection_id``provider` 字段
- `api_logs`: `tenant_id``connection_id``api_name``provider`
**新增视图:**
- `connection_stats` - 替换 `tenant_stats`
- `user_connections_view` - 用户连接视图
**新增触发器:**
- `set_invoice_provider_trigger` - 自动设置 provider 字段
---
**文档历史:**
| 版本 | 日期 | 作者 | 变更 |
|------|------|------|------|
| 3.0 | 2026-02-03 | Claude Code | 重构为 .NET + EF Core + 审计支持 |
| 2.0 | 2026-02-03 | Claude Code | 重构为多会计系统 Schema |
| 1.0 | 2026-02-03 | Claude Code | 初始版本 |

853
DEPLOYMENT_GUIDE.md Normal file
View File

@@ -0,0 +1,853 @@
# Invoice Master - 部署指南
**版本**: v3.0
**目标平台**: Azure
**运行时**: .NET 8
**日期**: 2026-02-03
---
## 1. 架构概览
### 1.1 多会计系统架构部署图
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Azure Sweden Central │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Azure Container Apps Environment │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ │ │
│ │ │ Frontend App │ │ Backend API │ │ Worker │ │ │
│ │ │ (Static Web) │ │ (FastAPI) │ │ (Background) │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Vercel/Azure │ │ CPU: 1 vCPU │ │ CPU: 0.5 │ │ │
│ │ │ Static Web │ │ Memory: 2 GiB │ │ Memory: 1GiB │ │ │
│ │ │ │ │ Replicas: 1-5 │ │ Replicas: 1-3│ │ │
│ │ └─────────────────┘ └─────────────────┘ └───────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Azure Application Gateway (WAF) │ │ │
│ │ │ SSL Termination │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ ┌─────────────────────────────────────────┐ │
│ │ PostgreSQL Flexible │ │ Azure Cache for Redis │ │
│ │ Server │ │ │ │
│ │ - SKU: Standard_B1ms │ │ - SKU: Basic C1 │ │
│ │ - Storage: 32 GB │ │ - Memory: 1 GB │ │
│ │ - Backup: 7 days │ │ │ │
│ │ - accounting_ │ │ - Multi-provider cache │ │
│ │ connections table │ │ │ │
│ └─────────────────────────┘ └─────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Azure Blob Storage │ │
│ │ - Tier: Hot │ │
│ │ - Redundancy: LRS │ │
│ │ - Invoice PDFs (multi-tenant) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Azure Key Vault │ │
│ │ - Encryption Keys │ │
│ │ - Fortnox Client Credentials │ │
│ │ - Visma Client Credentials (future) │ │
│ │ - Hogia Client Credentials (future) │ │
│ │ - Provider-specific configs │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 1.2 多会计系统配置
```
┌─────────────────────────────────────────────────────────────────┐
│ Key Vault Secrets Structure │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Global Secrets: │
│ ├── jwt-secret │
│ ├── encryption-key │
│ ├── db-password │
│ └── ocr-api-key │
│ │
│ Provider-Specific Secrets: │
│ ├── fortnox-client-id │
│ ├── fortnox-client-secret │
│ ├── fortnox-redirect-uri │
│ ├── visma-client-id (future) │
│ ├── visma-client-secret (future) │
│ └── visma-redirect-uri (future) │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 2. 前置要求
### 2.1 工具安装
```bash
# Azure CLI
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
# Terraform
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
# kubectl (可选)
az aks install-cli
```
### 2.2 Azure 登录
```bash
# 登录 Azure
az login
# 设置订阅
az account set --subscription "Your Subscription Name"
# 创建 Service Principal (用于 Terraform)
az ad sp create-for-rbac --name "invoice-master-terraform" --role Contributor \
--scopes /subscriptions/{subscription-id}
```
---
## 3. 基础设施部署
### 3.1 Terraform 配置
```bash
cd infrastructure/terraform
# 初始化
terraform init
# 创建变量文件
cat > terraform.tfvars <<EOF
# 基础配置
environment = "production"
location = "swedencentral"
resource_group_name = "rg-invoice-master-prod"
# 数据库
db_admin_username = "dbadmin"
db_admin_password = "YourSecurePassword123!"
db_sku = "Standard_B1ms"
db_storage_mb = 32768
# Redis
redis_sku = "Basic"
redis_family = "C"
redis_capacity = 1
# Container Apps
ca_cpu = "1.0"
ca_memory = "2.0Gi"
ca_min_replicas = 1
ca_max_replicas = 5
# 域名
domain_name = "app.invoice-master.app"
EOF
# 计划
terraform plan
# 应用
terraform apply
```
### 3.2 Terraform 模块结构
```
infrastructure/terraform/
├── main.tf # 主配置
├── variables.tf # 变量定义
├── outputs.tf # 输出
├── providers.tf # 提供商配置
├── backend.tf # 远程状态
├── terraform.tfvars # 变量值 (gitignore)
└── modules/
├── database/ # PostgreSQL 模块
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── cache/ # Redis 模块
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── storage/ # Blob Storage 模块
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── container_apps/ # Container Apps 模块
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── keyvault/ # Key Vault 模块
├── main.tf
├── variables.tf
└── outputs.tf
```
### 3.3 主要资源清单
| 资源 | 名称 | SKU/配置 |
|------|------|----------|
| Resource Group | rg-invoice-master-prod | Sweden Central |
| PostgreSQL | psql-invoice-master-prod | Standard_B1ms |
| Redis | redis-invoice-master-prod | Basic C1 |
| Blob Storage | stinvoicemasterprod | Standard LRS |
| Container Apps Env | cae-invoice-master-prod | Consumption |
| Backend App | ca-invoice-master-api | 1 CPU, 2Gi |
| Worker App | ca-invoice-master-worker | 0.5 CPU, 1Gi |
| Key Vault | kv-invoice-master-prod | Standard |
| Log Analytics | log-invoice-master-prod | Per GB2018 |
| Application Insights | ai-invoice-master-prod | Web |
---
## 4. 应用部署
### 4.1 构建 .NET 应用
#### 4.1.1 本地发布
```bash
cd backend/src/InvoiceMaster.API
# 发布 Release 版本
dotnet publish -c Release -o ./publish \
--runtime linux-x64 \
--self-contained false
# 或者自包含(无需运行时)
dotnet publish -c Release -o ./publish \
--runtime linux-x64 \
--self-contained true \
-p:PublishSingleFile=true
```
#### 4.1.2 Docker 构建
```dockerfile
# Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["src/InvoiceMaster.API/InvoiceMaster.API.csproj", "src/InvoiceMaster.API/"]
COPY ["src/InvoiceMaster.Core/InvoiceMaster.Core.csproj", "src/InvoiceMaster.Core/"]
COPY ["src/InvoiceMaster.Application/InvoiceMaster.Application.csproj", "src/InvoiceMaster.Application/"]
COPY ["src/InvoiceMaster.Infrastructure/InvoiceMaster.Infrastructure.csproj", "src/InvoiceMaster.Infrastructure/"]
COPY ["src/InvoiceMaster.Integrations/InvoiceMaster.Integrations.csproj", "src/InvoiceMaster.Integrations/"]
RUN dotnet restore "src/InvoiceMaster.API/InvoiceMaster.API.csproj"
COPY . .
WORKDIR "/src/src/InvoiceMaster.API"
RUN dotnet build "InvoiceMaster.API.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "InvoiceMaster.API.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "InvoiceMaster.API.dll"]
```
```bash
# 构建并推送镜像
docker build -t invoice-master-api:latest -f Dockerfile .
docker tag invoice-master-api:latest {acr-name}.azurecr.io/invoice-master-api:v1.0.0
# 推送到 ACR
az acr login --name {acr-name}
docker push {acr-name}.azurecr.io/invoice-master-api:v1.0.0
```
### 4.2 配置环境变量
在 Azure Key Vault 中创建以下 secrets:
#### 全局 Secrets
| Secret Name | 说明 |
|-------------|------|
| `db-password` | PostgreSQL 密码 |
| `jwt-secret` | JWT 签名密钥 |
| `encryption-key` | AES-256 加密密钥 |
| `ocr-api-key` | Invoice Master API Key |
#### Provider-Specific Secrets
| Secret Name | 说明 | 状态 |
|-------------|------|------|
| `fortnox-client-id` | Fortnox OAuth Client ID | Required |
| `fortnox-client-secret` | Fortnox OAuth Client Secret | Required |
| `fortnox-redirect-uri` | Fortnox OAuth Redirect URI | Required |
| `visma-client-id` | Visma OAuth Client ID | Future |
| `visma-client-secret` | Visma OAuth Client Secret | Future |
| `visma-redirect-uri` | Visma OAuth Redirect URI | Future |
```bash
# 示例: 添加 global secrets
az keyvault secret set --vault-name kv-invoice-master-prod \
--name "jwt-secret" \
--value "your-256-bit-secret-key-here"
# 示例: 添加 Fortnox secrets
az keyvault secret set --vault-name kv-invoice-master-prod \
--name "fortnox-client-id" \
--value "your-fortnox-client-id"
az keyvault secret set --vault-name kv-invoice-master-prod \
--name "fortnox-client-secret" \
--value "your-fortnox-client-secret"
```
### 4.3 部署到 Container Apps
```bash
# 更新后端应用
az containerapp update \
--name ca-invoice-master-api \
--resource-group rg-invoice-master-prod \
--image {acr-name}.azurecr.io/invoice-master-api:v1.0.0 \
--set-env-vars "ENVIRONMENT=production"
```
### 4.4 数据库迁移 (EF Core)
```bash
# 方法 1: 使用 EF Core CLI (推荐开发环境)
cd backend/src/InvoiceMaster.Infrastructure
# 添加迁移
dotnet ef migrations add InitialCreate \
--startup-project ../InvoiceMaster.API
# 更新数据库
dotnet ef database update \
--startup-project ../InvoiceMaster.API \
--connection "Host=psql-invoice-master-prod.postgres.database.azure.com;Database=invoice_master;Username=dbadmin;Password=$DB_PASSWORD"
# 生成 SQL 脚本 (生产环境推荐)
dotnet ef migrations script \
--startup-project ../InvoiceMaster.API \
--output migration.sql
# 执行 SQL 脚本
psql "Host=psql-invoice-master-prod.postgres.database.azure.com;Database=invoice_master;Username=dbadmin" \
-f migration.sql
```
#### 4.4.1 生产环境迁移最佳实践
```bash
# 使用临时 Container App Job 运行迁移
az containerapp job create \
--name db-migration-job \
--resource-group rg-invoice-master-prod \
--environment cae-invoice-master-prod \
--image {acr-name}.azurecr.io/invoice-master-api:v1.0.0 \
--command "dotnet ef database update" \
--env-vars "ConnectionStrings__DefaultConnection=secretref:db-connection-string"
```
### 4.5 添加新的会计系统 Provider
当需要添加新的会计系统时:
```bash
# 1. 在 Key Vault 中添加新 Provider 的 credentials
az keyvault secret set \
--vault-name kv-invoice-master-prod \
--name "visma-client-id" \
--value "your-visma-client-id"
# 2. 更新 Container Apps 环境变量
az containerapp update \
--name ca-invoice-master-api \
--resource-group rg-invoice-master-prod \
--set-env-vars "VISMA_CLIENT_ID=secretref:visma-client-id"
# 3. 重启应用
az containerapp revision restart \
--name ca-invoice-master-api \
--resource-group rg-invoice-master-prod
```
---
## 5. 前端部署
### 5.1 构建
```bash
cd frontend
# 安装依赖
npm install
# 构建生产版本
npm run build
```
### 5.2 部署到 Azure Static Web Apps
```bash
# 使用 SWA CLI
npm install -g @azure/static-web-apps-cli
# 部署
swa deploy ./dist \
--env production \
--app-name stapp-invoice-master-prod \
--resource-group rg-invoice-master-prod
```
### 5.3 或部署到 Vercel
```bash
# 安装 Vercel CLI
npm i -g vercel
# 部署
vercel --prod
```
---
## 6. 域名和 SSL
### 6.1 配置自定义域名
```bash
# Container Apps 自定义域名
az containerapp hostname add \
--name ca-invoice-master-api \
--resource-group rg-invoice-master-prod \
--hostname api.invoice-master.app
# 绑定证书 (Managed Certificate)
az containerapp hostname bind \
--name ca-invoice-master-api \
--resource-group rg-invoice-master-prod \
--hostname api.invoice-master.app \
--environment cae-invoice-master-prod \
--validation-method CNAME
```
### 6.2 DNS 配置
在 DNS 提供商处添加以下记录:
| 类型 | 主机 | 值 |
|------|------|-----|
| CNAME | api | ca-invoice-master-api.{region}.azurecontainerapps.io |
| CNAME | app | {static-web-app-url} |
---
## 7. 监控配置
### 7.1 Application Insights
```bash
# 获取连接字符串
APP_INSIGHTS_CONN=$(az monitor app-insights component show \
--app ai-invoice-master-prod \
--resource-group rg-invoice-master-prod \
--query connectionString -o tsv)
# 配置到 Container Apps
az containerapp update \
--name ca-invoice-master-api \
--resource-group rg-invoice-master-prod \
--set-env-vars "APPLICATIONINSIGHTS_CONNECTION_STRING=$APP_INSIGHTS_CONN"
```
### 7.2 多会计系统监控
```kusto
// 查看各 Provider API 调用情况
AppRequests
| where TimeGenerated > ago(1h)
| where OperationName contains "accounting"
| summarize count(), avg(DurationMs) by Provider = tostring(CustomDimensions.provider)
| order by count_ desc
// 查看 Provider 错误率
AppExceptions
| where TimeGenerated > ago(1h)
| where ProblemId contains "fortnox" or ProblemId contains "visma"
| summarize count() by Provider = tostring(CustomDimensions.provider), bin(TimeGenerated, 5m)
| render timechart
```
### 7.3 告警规则
```bash
# 创建 CPU 使用率告警
az monitor metrics alert create \
--name "High CPU Alert" \
--resource-group rg-invoice-master-prod \
--scopes $(az containerapp show --name ca-invoice-master-api --resource-group rg-invoice-master-prod --query id -o tsv) \
--condition "avg cpu percentage > 80" \
--window-size 5m \
--evaluation-frequency 1m \
--action $(az monitor action-group show --name ag-invoice-master --resource-group rg-invoice-master-prod --query id -o tsv)
# 创建 Provider API 错误告警
az monitor metrics alert create \
--name "Provider API Errors" \
--resource-group rg-invoice-master-prod \
--scopes $(az monitor app-insights component show --name ai-invoice-master-prod --resource-group rg-invoice-master-prod --query id -o tsv) \
--condition "count exceptions > 10" \
--window-size 5m \
--evaluation-frequency 1m
```
---
## 8. 备份策略
### 8.1 数据库备份
```bash
# 配置自动备份 (已在 Terraform 中配置)
# 手动备份
az postgres flexible-server backup create \
--name manual-backup-$(date +%Y%m%d) \
--server-name psql-invoice-master-prod \
--resource-group rg-invoice-master-prod
# 备份 accounting_connections 表 (关键)
pg_dump "host=psql-invoice-master-prod.postgres.database.azure.com user=dbadmin dbname=invoice_master sslmode=require" \
--table=accounting_connections \
--table=invoices \
--table=users > critical_tables_backup.sql
```
### 8.2 Blob Storage 备份
```bash
# 启用版本控制
az storage account blob-service-properties update \
--account-name stinvoicemasterprod \
--enable-versioning true
# 配置生命周期策略
az storage account management-policy create \
--account-name stinvoicemasterprod \
--policy @lifecycle-policy.json
```
---
## 9. 安全加固
### 9.1 网络安全
```bash
# 配置防火墙规则
az postgres flexible-server firewall-rule create \
--name AllowContainerApps \
--server-name psql-invoice-master-prod \
--resource-group rg-invoice-master-prod \
--start-ip-address 0.0.0.0 \
--end-ip-address 0.0.0.0
# 启用私有链接 (可选)
```
### 9.2 Key Vault 访问策略
```bash
# 授予 Container Apps 访问 Key Vault 的权限
az keyvault set-policy \
--name kv-invoice-master-prod \
--object-id $(az containerapp show --name ca-invoice-master-api --resource-group rg-invoice-master-prod --query identity.principalId -o tsv) \
--secret-permissions get list
# 为新的 Provider 添加 secrets 时,确保权限已配置
```
### 9.3 多租户数据隔离
```sql
-- 验证数据隔离
SELECT provider, COUNT(*) as connection_count
FROM accounting_connections
GROUP BY provider;
-- 检查用户数据访问权限
SELECT u.email, ac.provider, ac.company_name
FROM users u
JOIN accounting_connections ac ON u.id = ac.user_id
WHERE u.email = 'test@example.com';
```
---
## 10. 回滚策略
### 10.1 应用回滚
```bash
# 回滚到上一个版本
az containerapp revision list \
--name ca-invoice-master-api \
--resource-group rg-invoice-master-prod
az containerapp update \
--name ca-invoice-master-api \
--resource-group rg-invoice-master-prod \
--revision-suffix {previous-revision}
```
### 10.2 数据库回滚
```bash
# 从备份恢复
az postgres flexible-server restore \
--name psql-invoice-master-prod-restored \
--resource-group rg-invoice-master-prod \
--source-server psql-invoice-master-prod \
--point-in-time "2026-02-03T10:00:00Z"
# 回滚特定 Provider 的数据 (谨慎操作)
DELETE FROM accounting_connections WHERE provider = 'fortnox';
```
### 10.3 Provider 配置回滚
```bash
# 如果新 Provider 配置出错,快速禁用
az containerapp update \
--name ca-invoice-master-api \
--resource-group rg-invoice-master-prod \
--set-env-vars "ENABLED_PROVIDERS=fortnox"
```
---
## 11. CI/CD 配置
### 11.1 GitHub Actions 工作流
```yaml
# .github/workflows/deploy.yml
name: Deploy to Azure
on:
push:
branches: [main]
jobs:
deploy-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Build and Push
run: |
az acr build --registry ${{ secrets.ACR_NAME }} \
--image invoice-master-api:${{ github.sha }} \
./backend
- name: Deploy
run: |
az containerapp update \
--name ca-invoice-master-api \
--resource-group rg-invoice-master-prod \
--image ${{ secrets.ACR_NAME }}.azurecr.io/invoice-master-api:${{ github.sha }}
- name: Run Migrations
run: |
# 使用临时容器运行 EF Core 迁移
az containerapp job create \
--name migration-job \
--resource-group rg-invoice-master-prod \
--image ${{ secrets.ACR_NAME }}.azurecr.io/invoice-master-api:${{ github.sha }} \
--command "dotnet ef database update --no-build"
```
### 11.2 多环境部署
```yaml
# 不同环境使用不同配置
- name: Deploy to Staging
if: github.ref == 'refs/heads/develop'
run: |
az containerapp update \
--name ca-invoice-master-api-staging \
--resource-group rg-invoice-master-staging
- name: Deploy to Production
if: github.ref == 'refs/heads/main'
run: |
az containerapp update \
--name ca-invoice-master-api \
--resource-group rg-invoice-master-prod
```
---
## 12. 成本估算
### 12.1 基础资源成本
| 资源 | 每月估算 (SEK) |
|------|----------------|
| PostgreSQL (B1ms) | ~150 |
| Redis (Basic C1) | ~100 |
| Container Apps | ~200-500 |
| Blob Storage | ~50-200 |
| Key Vault | ~30 |
| Application Insights | ~100 |
| **基础总计** | **~630-1,080 SEK** |
### 12.2 多会计系统额外成本
| 项目 | 每月估算 (SEK) | 说明 |
|------|----------------|------|
| 额外的 API 调用 | ~0-100 | 取决于 Provider 数量 |
| 额外的 Redis 缓存 | ~0-50 | Provider token 缓存 |
| 额外的日志存储 | ~0-100 | 多 Provider 日志 |
| **额外总计** | **~0-250 SEK** |
---
## 13. 故障排除
### 13.1 常见问题
**问题**: Container App 无法启动
```bash
# 查看日志
az containerapp logs show \
--name ca-invoice-master-api \
--resource-group rg-invoice-master-prod \
--follow
```
**问题**: Provider 认证失败
```bash
# 检查 Key Vault secrets
az keyvault secret list --vault-name kv-invoice-master-prod
# 验证特定 Provider 配置
curl https://api.invoice-master.app/api/v1/accounting/providers \
-H "Authorization: Bearer $TOKEN"
```
**问题**: 数据库连接失败
```bash
# 测试连接
psql "host=psql-invoice-master-prod.postgres.database.azure.com port=5432 dbname=invoice_master user=dbadmin sslmode=require"
# 检查连接表
psql $DB_URL -c "SELECT provider, COUNT(*) FROM accounting_connections GROUP BY provider;"
```
### 13.2 Provider 特定故障排除
**Fortnox OAuth 问题**:
```bash
# 检查 Fortnox 连接状态
curl https://api.invoice-master.app/api/v1/accounting/fortnox/connection \
-H "Authorization: Bearer $TOKEN"
# 重新授权
# 引导用户访问: https://api.invoice-master.app/api/v1/accounting/fortnox/auth/url
```
### 13.3 健康检查
```bash
# API 健康检查
curl https://api.invoice-master.app/health
# 详细健康检查 (包含 Provider 状态)
curl https://api.invoice-master.app/health/detailed \
-H "Authorization: Bearer $ADMIN_TOKEN"
# 预期响应:
# {
# "status": "healthy",
# "providers": {
# "fortnox": { "status": "connected", "latency_ms": 150 },
# "visma": { "status": "not_configured" }
# }
# }
```
---
## 14. 维护窗口
| 任务 | 频率 | 时间 |
|------|------|------|
| 安全更新 | 每周 | 周日 02:00-04:00 |
| 数据库备份验证 | 每月 | 第一个周日 |
| Provider API 健康检查 | 每周 | 周一 |
| 性能审查 | 每季度 | - |
| 证书续期检查 | 每月 | - |
| Provider SDK 更新检查 | 每月 | - |
---
## 15. 扩展检查清单
### 添加新 Provider 时的部署检查清单
- [ ] 在 Key Vault 中添加 Provider credentials
- [ ] 更新 Container Apps 环境变量
- [ ] 更新 DNS/防火墙规则 (如需要)
- [ ] 测试 Provider 连接
- [ ] 更新监控告警规则
- [ ] 更新文档
- [ ] 通知团队
---
## 16. 联系信息
| 角色 | 联系方式 |
|------|----------|
| 技术负责人 | tech@example.com |
| 运维团队 | ops@example.com |
| 紧急联系 | +46-xxx-xxx-xxxx |
| Provider 支持 | providers@example.com |
---
**文档历史:**
| 版本 | 日期 | 作者 | 变更 |
|------|------|------|------|
| 3.0 | 2026-02-03 | Claude Code | 重构为 .NET 8 + EF Core 部署 |
| 2.0 | 2026-02-03 | Claude Code | 更新为多会计系统架构 |
| 1.0 | 2026-02-03 | Claude Code | 初始版本 |

496
DEVELOPMENT_PLAN.md Normal file
View File

@@ -0,0 +1,496 @@
# Invoice Master - 开发计划
**版本**: v3.0
**技术栈**: .NET 8 + ASP.NET Core + EF Core
**开始日期**: 2026-02-03
**预计周期**: 14 周(含 .NET 重构 + 多会计系统架构)
---
## 1. 里程碑总览
| 阶段 | 时间 | 目标 | 交付物 |
|------|------|------|--------|
| **M0** | Pre-Phase | 架构重构 | 多会计系统抽象层、数据库迁移 |
| **M1** | Week 1-2 | 基础架构 | 项目框架、数据库、认证模块 |
| **M2** | Week 3-4 | 核心功能 | OCR 集成、文件上传、供应商匹配 |
| **M3** | Week 5-6 | 会计系统集成 | 抽象层实现、Fortnox Provider |
| **M4** | Week 7-8 | UI 开发 | 前端界面、用户流程 |
| **M5** | Week 9-10 | 测试优化 | 单元测试、集成测试、性能优化 |
| **M6** | Week 11-12 | 上线准备 | 文档、审核、部署 |
| **M7** | Week 13-14 | 扩展准备 | Provider SDK、文档、示例 |
---
## 2. 详细任务分解
### Pre-Phase: 多会计系统架构重构 (Week 0)
#### 架构设计
**后端任务:**
- [x] 设计会计系统抽象层接口 (AccountingSystem ABC)
- [x] 创建通用数据模型 (Supplier, Voucher, Account)
- [x] 实现 Factory 模式注册机制
- [x] 重构数据库模型支持多会计系统
- [x] `accounting_connections` 表 (通用连接表)
- [x] `invoices` 表 (通用发票表)
- [x] `supplier_cache` 表 (通用供应商缓存)
- [x] 迁移脚本更新
- [ ] API 路由重构为通用接口
- [ ] 更新依赖注入支持多 Provider
**文档:**
- [x] 更新 ARCHITECTURE.md
- [ ] 更新 API_DESIGN.md
- [x] 更新 DATABASE_SCHEMA.md
---
### Phase 1: 基础架构 (Week 1-2)
#### Week 1: 项目初始化 (.NET)
**后端任务:**
- [ ] 创建 .NET Solution 结构
- [ ] `dotnet new sln -n InvoiceMaster`
- [ ] 创建 5 个项目: API, Core, Application, Infrastructure, Integrations
- [ ] 配置项目引用关系
- [ ] 配置 ASP.NET Core Web API
- [ ] Program.cs 配置
- [ ] appsettings.json 配置
- [ ] Swagger/OpenAPI 配置
- [ ] 设置 Docker 开发环境 (mcr.microsoft.com/dotnet/sdk:8.0)
- [ ] 配置 EF Core + PostgreSQL
- [ ] 安装 Npgsql.EntityFrameworkCore.PostgreSQL
- [ ] 配置 DbContext
- [ ] 配置连接字符串
- [ ] 配置依赖注入容器
- [ ] 设置 Serilog 结构化日志
- [ ] 配置代码质量工具 (EditorConfig, StyleCop)
**前端任务:**
- [ ] 初始化 React + Vite 项目
- [ ] 配置 TypeScript
- [ ] 配置 TailwindCSS
- [ ] 设置 ESLint + Prettier
- [ ] 配置 React Router
- [ ] 设置 Zustand 状态管理
- [ ] 配置 React Query
**基础设施:**
- [x] 创建 Docker Compose 开发环境
- [x] 配置本地 PostgreSQL
- [x] 配置本地 Redis
#### Week 2: 认证模块 (.NET Identity)
**后端任务:**
- [ ] 配置 ASP.NET Core Identity
- [ ] 安装 Microsoft.AspNetCore.Identity.EntityFrameworkCore
- [ ] 配置 IdentityDbContext
- [ ] 配置 IdentityOptions (密码策略、锁定等)
- [ ] 实现 JWT 认证
- [ ] 配置 JWT Bearer 认证
- [ ] 实现 Token 生成服务
- [ ] 实现 Token 刷新机制
- [ ] 实现认证 API (Minimal API 或 Controllers)
- [ ] POST /auth/register
- [ ] POST /auth/login
- [ ] POST /auth/refresh
- [ ] POST /auth/logout
- [ ] 配置授权策略
- [ ] 创建 EF Core 迁移
**前端任务:**
- [ ] 创建登录页面
- [ ] 创建注册页面
- [ ] 实现认证 API 客户端
- [ ] 实现认证状态管理
- [ ] 创建受保护路由组件
- [ ] 实现 Token 自动刷新
**测试:**
- [ ] 单元测试: 认证服务 (xUnit + Moq)
- [ ] 单元测试: JWT 工具
- [ ] 集成测试: 认证流程 (WebApplicationFactory)
---
### Phase 2: 核心功能 (Week 3-4)
#### Week 3: OCR 集成与文件处理
**后端任务:**
- [ ] 设计发票模型
- [ ] 实现 Azure Blob Storage 客户端
- [ ] 实现文件上传 API
- [ ] 集成 Invoice Master OCR API
- [ ] 创建 OCR 客户端
- [ ] 实现异步 OCR 调用
- [ ] 解析 OCR 响应
- [ ] 实现文件验证 (类型、大小)
- [ ] 实现文件去重 (hash 检查)
- [ ] 创建发票处理队列
**前端任务:**
- [ ] 创建文件上传组件
- [ ] 拖放上传
- [ ] 进度显示
- [ ] 文件预览
- [ ] 实现上传 API 集成
- [ ] 创建处理状态显示
- [ ] 实现发票列表页面
**测试:**
- [ ] 单元测试: 文件服务
- [ ] 单元测试: OCR 客户端
- [ ] 集成测试: 上传流程
#### Week 4: 供应商匹配
**后端任务:**
- [ ] 设计供应商缓存模型
- [ ] 实现供应商匹配算法
- [ ] 组织号精确匹配
- [ ] 名称模糊匹配 (fuzzywuzzy)
- [ ] 实现供应商缓存服务
- [ ] 创建供应商 API 端点
- [ ] 获取供应商列表
- [ ] 刷新缓存
- [ ] 实现匹配结果存储
**前端任务:**
- [ ] 创建供应商匹配组件
- [ ] 匹配结果显示
- [ ] 置信度指示器
- [ ] 手动选择供应商
- [ ] 实现供应商 API 集成
- [ ] 创建供应商选择弹窗
**测试:**
- [ ] 单元测试: 供应商匹配算法
- [ ] 单元测试: 缓存服务
- [ ] 集成测试: 匹配流程
---
### Phase 3: 会计系统集成 (Week 5-6)
#### Week 5: 会计系统抽象层实现
**后端任务:**
- [x] 创建抽象基类 `AccountingSystem`
- [x] 实现通用数据模型
- [x] `AccountingSupplier`
- [x] `AccountingVoucher`
- [x] `AccountingAccount`
- [x] `CompanyInfo`
- [x] 实现 Factory 模式
- [x] `AccountingSystemFactory`
- [x] Provider 注册机制
- [x] 创建 Fortnox Provider
- [x] OAuth2 认证实现
- [x] Token 刷新机制
- [x] 供应商 API 实现
- [x] 凭证 API 实现
- [x] 文件上传实现
- [ ] 抽象层单元测试
**前端任务:**
- [ ] 创建会计系统选择组件
- [ ] 实现多会计系统连接页面
- [ ] 连接状态管理
**测试:**
- [ ] 单元测试: Fortnox Provider
- [ ] 单元测试: Factory 模式
- [ ] Mock 测试: 外部 API
#### Week 6: 凭证生成与导入
**后端任务:**
- [ ] 实现会计科目选择逻辑
- [ ] 实现 VAT 计算
- [ ] 实现凭证生成服务
- [ ] 构建凭证行
- [ ] 科目分配
- [ ] 实现凭证创建 API (通过抽象层)
- [ ] 实现文件附件上传 (通过抽象层)
- [ ] 实现导入状态跟踪
- [ ] 错误处理和重试机制
**前端任务:**
- [ ] 创建凭证预览组件
- [ ] 凭证行显示
- [ ] 科目选择
- [ ] 金额编辑
- [ ] 创建审核页面
- [ ] OCR 结果编辑
- [ ] 供应商确认
- [ ] 凭证预览
- [ ] 实现导入 API 集成
**测试:**
- [ ] 单元测试: 凭证生成
- [ ] 单元测试: VAT 计算
- [ ] 集成测试: 导入流程
---
### Phase 4: UI 开发 (Week 7-8)
#### Week 7: 核心页面开发
**前端任务:**
- [ ] 仪表盘页面
- [ ] 统计卡片
- [ ] 最近发票列表
- [ ] 快速上传入口
- [ ] 会计系统连接状态
- [ ] 发票详情页面
- [ ] PDF 预览
- [ ] 提取数据显示
- [ ] 操作按钮
- [ ] 历史记录页面
- [ ] 筛选和搜索
- [ ] 分页
- [ ] 状态标签
- [ ] 响应式布局优化
**设计:**
- [ ] 完善 UI 组件库
- [ ] 统一颜色和字体
- [ ] 动画和过渡效果
#### Week 8: 用户体验优化
**前端任务:**
- [ ] 错误处理
- [ ] 错误提示组件
- [ ] 重试机制
- [ ] 加载状态
- [ ] 骨架屏
- [ ] 加载动画
- [ ] 空状态设计
- [ ] 确认对话框
- [ ] Toast 通知系统
- [ ] 键盘快捷键
**后端任务:**
- [ ] 优化 API 响应时间
- [ ] 实现请求缓存
- [ ] 添加 API 文档 (Swagger)
---
### Phase 5: 测试优化 (Week 9-10)
#### Week 9: 测试覆盖
**后端测试:**
- [ ] 单元测试覆盖 > 80%
- [ ] 服务层测试
- [ ] 抽象层测试
- [ ] Provider 测试
- [ ] 工具函数测试
- [ ] 集成测试
- [ ] API 端点测试
- [ ] 数据库操作测试
- [ ] Mock 外部服务
- [ ] Fortnox API Mock
- [ ] OCR API Mock
**前端测试:**
- [ ] 组件单元测试 (Vitest)
- [ ] 集成测试 (React Testing Library)
- [ ] E2E 测试 (Playwright)
- [ ] 登录流程
- [ ] 上传流程
- [ ] 导入流程
#### Week 10: 性能优化
**后端优化:**
- [ ] 数据库查询优化
- [ ] 添加必要索引
- [ ] 查询优化
- [ ] 缓存策略
- [ ] Redis 缓存
- [ ] 响应缓存
- [ ] 连接池优化
- [ ] 异步任务优化
**前端优化:**
- [ ] 代码分割
- [ ] 懒加载
- [ ] 图片优化
- [ ] 缓存策略
**安全审计:**
- [ ] 依赖漏洞扫描
- [ ] 代码安全审查
- [ ] OWASP 检查
---
### Phase 6: 上线准备 (Week 11-12)
#### Week 11: 文档与审核
**文档:**
- [ ] API 文档完善
- [ ] 用户手册
- [ ] 部署文档
- [ ] 运维手册
- [ ] 代码注释
- [ ] Provider 开发指南 (SDK 文档)
**Fortnox 审核:**
- [ ] 创建 Fortnox 开发者账号
- [ ] 提交应用审核
- [ ] 准备审核材料
- [ ] 应用描述
- [ ] 隐私政策
- [ ] 使用条款
- [ ] 处理审核反馈
**监控:**
- [ ] 配置 Azure Monitor
- [ ] 设置告警规则
- [ ] 配置日志聚合
#### Week 12: 部署上线
**基础设施:**
- [ ] 生产环境部署
- [ ] Azure Container Apps
- [ ] PostgreSQL
- [ ] Redis
- [ ] Blob Storage
- [ ] 域名配置
- [ ] SSL 证书
- [ ] CDN 配置
**上线检查:**
- [ ] 功能测试
- [ ] 性能测试
- [ ] 安全测试
- [ ] 备份策略验证
- [ ] 回滚计划
**发布:**
- [ ] 软发布 (Beta)
- [ ] 收集反馈
- [ ] 正式发布
---
### Phase 7: 扩展准备 (Week 13-14)
#### Week 13: Provider SDK 开发
**后端任务:**
- [ ] 创建 Provider 开发模板
- [ ] 实现基础 Provider 类
- [ ] 创建 Provider 脚手架工具
- [ ] 编写 Provider 测试模板
- [ ] 实现 Provider 验证工具
**文档:**
- [ ] Provider 开发指南
- [ ] API 差异对比文档
- [ ] 最佳实践指南
- [ ] 故障排查指南
#### Week 14: 示例与工具
**后端任务:**
- [ ] 创建 Mock Provider 示例
- [ ] 实现 Provider 调试工具
- [ ] 创建 Provider 性能测试工具
- [ ] 实现 Provider 文档生成器
**文档:**
- [ ] 架构决策记录 (ADR)
- [ ] 技术规范文档
- [ ] 维护手册
---
## 3. 技术债务管理
| 优先级 | 任务 | 计划解决时间 |
|--------|------|-------------|
| P0 | 多会计系统架构重构 | Pre-Phase |
| P1 | 添加数据库连接池监控 | Week 9 |
| P1 | 实现分布式锁 (Redis) | Week 10 |
| P2 | 添加 API 版本控制 | Week 11 |
| P2 | 实现事件溯源 | Phase 2 |
| P3 | 多语言支持 | Phase 2 |
| P3 | 添加第二个会计系统 (Visma) | Phase 2 |
---
## 4. 风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 架构重构延期 | 高 | 优先完成核心抽象层,细节后续迭代 |
| 不同会计系统 API 差异大 | 中 | 抽象层设计预留扩展点,文档化差异 |
| Fortnox API 限流 | 高 | 实现缓存、队列、退避策略 |
| OCR 准确率不足 | 高 | 用户审核流程、置信度阈值 |
| Token 过期处理 | 中 | 自动刷新、用户提示重连 |
| 供应商匹配失败 | 中 | 模糊匹配、手动选择 |
| 文件存储成本 | 中 | 压缩、生命周期策略 |
---
## 5. 团队分工
| 角色 | 职责 | 主要任务 |
|------|------|----------|
| **架构师** | 系统设计 | 抽象层设计、架构评审、技术决策 |
| **后端开发** | API 开发 | 认证、业务逻辑、抽象层、Provider |
| **前端开发** | UI 开发 | 组件、页面、状态管理 |
| **DevOps** | 基础设施 | 部署、监控、CI/CD |
| **QA** | 测试 | 测试用例、自动化测试 |
| **技术写作** | 文档 | Provider SDK 文档、开发指南 |
---
## 6. 代码审查清单
- [ ] 代码符合项目规范
- [ ] 抽象层接口设计合理
- [ ] Provider 实现符合接口规范
- [ ] 有适当的单元测试
- [ ] 没有安全漏洞
- [ ] 性能考虑
- [ ] 文档已更新
- [ ] 数据库迁移已包含 (如需要)
- [ ] Provider 注册正确
---
## 7. 发布检查清单
- [ ] 所有测试通过
- [ ] 代码审查完成
- [ ] 架构文档已更新
- [ ] Provider SDK 文档完成
- [ ] 数据库迁移已测试
- [ ] 环境变量已配置
- [ ] 监控和告警已配置
- [ ] 回滚计划已准备
- [ ] 团队成员已通知
---
**文档历史:**
| 版本 | 日期 | 作者 | 变更 |
|------|------|------|------|
| 3.0 | 2026-02-03 | Claude Code | 重构为 .NET 8 + 轻量级 DDD + 审计支持 |
| 2.0 | 2026-02-03 | Claude Code | 添加多会计系统架构重构计划 |
| 1.0 | 2026-02-03 | Claude Code | 初始版本 |

424
DIRECTORY_STRUCTURE.md Normal file
View File

@@ -0,0 +1,424 @@
# Invoice Master - 项目目录结构
**版本**: v3.0
**技术栈**: .NET 8 + React 18
```
invoice-master/
├── README.md # 项目说明
├── ARCHITECTURE.md # 架构文档
├── API_DESIGN.md # API 设计文档
├── DATABASE_SCHEMA.md # 数据库设计
├── DEVELOPMENT_PLAN.md # 开发计划
├── DEPLOYMENT_GUIDE.md # 部署指南
├── .gitignore # Git 忽略配置
├── docker-compose.yml # 本地开发环境
├── docker-compose.prod.yml # 生产环境配置
├── backend/ # .NET 后端
│ ├── InvoiceMaster.sln # Solution 文件
│ ├── Directory.Build.props # 全局 MSBuild 属性
│ ├── global.json # .NET SDK 版本
│ ├── Dockerfile # 容器镜像
│ ├── docker-compose.yml # 本地开发编排
│ │
│ ├── src/ # 源代码
│ │ ├── InvoiceMaster.API/ # Web API 入口
│ │ │ ├── InvoiceMaster.API.csproj
│ │ │ ├── Program.cs
│ │ │ ├── appsettings.json
│ │ │ ├── appsettings.Development.json
│ │ │ ├── Controllers/ # API 控制器
│ │ │ │ ├── AuthController.cs
│ │ │ │ ├── InvoicesController.cs
│ │ │ │ └── AccountingController.cs
│ │ │ ├── Middleware/ # 中间件
│ │ │ │ ├── ExceptionHandlingMiddleware.cs
│ │ │ │ └── RequestLoggingMiddleware.cs
│ │ │ └── Properties/
│ │ │ └── launchSettings.json
│ │ │
│ │ ├── InvoiceMaster.Core/ # 领域层
│ │ │ ├── InvoiceMaster.Core.csproj
│ │ │ ├── Entities/ # 领域实体
│ │ │ │ ├── Invoice.cs
│ │ │ │ ├── AccountingConnection.cs
│ │ │ │ ├── User.cs
│ │ │ │ └── SupplierCache.cs
│ │ │ ├── Events/ # 领域事件
│ │ │ │ ├── DomainEvent.cs
│ │ │ │ ├── InvoiceImportedEvent.cs
│ │ │ │ └── ConnectionCreatedEvent.cs
│ │ │ ├── Interfaces/ # 领域接口
│ │ │ │ ├── IRepository.cs
│ │ │ │ ├── IUnitOfWork.cs
│ │ │ │ └── IDomainEventDispatcher.cs
│ │ │ └── ValueObjects/ # 值对象
│ │ │ ├── Money.cs
│ │ │ └── OrganizationNumber.cs
│ │ │
│ │ ├── InvoiceMaster.Application/ # 应用层
│ │ │ ├── InvoiceMaster.Application.csproj
│ │ │ ├── Commands/ # CQRS 命令
│ │ │ │ ├── ImportInvoice/
│ │ │ │ │ ├── ImportInvoiceCommand.cs
│ │ │ │ │ └── ImportInvoiceCommandHandler.cs
│ │ │ │ └── CreateConnection/
│ │ │ │ ├── CreateConnectionCommand.cs
│ │ │ │ └── CreateConnectionCommandHandler.cs
│ │ │ ├── Queries/ # CQRS 查询
│ │ │ │ ├── GetInvoice/
│ │ │ │ │ ├── GetInvoiceQuery.cs
│ │ │ │ │ └── GetInvoiceQueryHandler.cs
│ │ │ │ └── ListInvoices/
│ │ │ │ ├── ListInvoicesQuery.cs
│ │ │ │ └── ListInvoicesQueryHandler.cs
│ │ │ ├── DTOs/ # 数据传输对象
│ │ │ │ ├── InvoiceDto.cs
│ │ │ │ └── ConnectionDto.cs
│ │ │ ├── Mappings/ # AutoMapper 配置
│ │ │ │ └── MappingProfile.cs
│ │ │ └── Behaviors/ # MediatR 行为
│ │ │ ├── ValidationBehavior.cs
│ │ │ └── LoggingBehavior.cs
│ │ │
│ │ ├── InvoiceMaster.Infrastructure/# 基础设施层
│ │ │ ├── InvoiceMaster.Infrastructure.csproj
│ │ │ ├── Data/ # EF Core
│ │ │ │ ├── ApplicationDbContext.cs
│ │ │ │ ├── Configurations/ # 实体配置
│ │ │ │ │ ├── InvoiceConfiguration.cs
│ │ │ │ │ └── ConnectionConfiguration.cs
│ │ │ │ ├── Migrations/ # 迁移文件
│ │ │ │ └── Interceptors/ # EF 拦截器
│ │ │ │ └── DispatchDomainEventsInterceptor.cs
│ │ │ ├── Repositories/ # 仓储实现
│ │ │ │ ├── InvoiceRepository.cs
│ │ │ │ └── ConnectionRepository.cs
│ │ │ ├── Services/ # 外部服务
│ │ │ │ ├── BlobStorageService.cs
│ │ │ │ └── OCRService.cs
│ │ │ └── Identity/ # Identity 配置
│ │ │ └── ApplicationUser.cs
│ │ │
│ │ └── InvoiceMaster.Integrations/# 集成层
│ │ ├── InvoiceMaster.Integrations.csproj
│ │ ├── Accounting/ # 会计系统集成
│ │ │ ├── IAccountingSystem.cs
│ │ │ ├── IAccountingSystemFactory.cs
│ │ │ ├── AccountingSystemFactory.cs
│ │ │ ├── Models/ # 通用模型
│ │ │ │ ├── Supplier.cs
│ │ │ │ ├── Voucher.cs
│ │ │ │ └── Account.cs
│ │ │ └── Providers/ # Provider 实现
│ │ │ ├── Fortnox/
│ │ │ │ ├── FortnoxProvider.cs
│ │ │ │ ├── FortnoxAuthClient.cs
│ │ │ │ └── FortnoxApiClient.cs
│ │ │ └── Visma/ # Future
│ │ │ └── VismaProvider.cs
│ │ └── Extensions/ # DI 扩展
│ │ └── AccountingServiceExtensions.cs
│ │
│ └── tests/ # 测试项目
│ ├── InvoiceMaster.UnitTests/
│ │ ├── InvoiceMaster.UnitTests.csproj
│ │ ├── Commands/
│ │ ├── Queries/
│ │ └── Domain/
│ ├── InvoiceMaster.IntegrationTests/
│ │ ├── InvoiceMaster.IntegrationTests.csproj
│ │ ├── Factories/
│ │ │ └── CustomWebApplicationFactory.cs
│ │ └── Controllers/
│ └── InvoiceMaster.ArchitectureTests/
│ └── InvoiceMaster.ArchitectureTests.csproj
├── frontend/ # React 前端
│ ├── package.json # 依赖管理
│ ├── tsconfig.json # TypeScript 配置
│ ├── vite.config.ts # Vite 配置
│ ├── tailwind.config.js # TailwindCSS 配置
│ ├── index.html # 入口 HTML
│ ├── .env.example # 环境变量示例
│ │
│ ├── src/
│ │ ├── main.tsx # 应用入口
│ │ ├── App.tsx # 根组件
│ │ ├── index.css # 全局样式
│ │ │
│ │ ├── api/ # API 客户端
│ │ │ ├── client.ts # Axios 实例
│ │ │ ├── auth.ts # 认证相关 API
│ │ │ ├── invoices.ts # 发票相关 API
│ │ │ ├── suppliers.ts # 供应商相关 API
│ │ │ ├── accounting.ts # 会计系统集成 API (多系统)
│ │ │ └── providers/ # Provider 特定 API
│ │ │ ├── fortnox.ts # Fortnox API
│ │ │ ├── visma.ts # Visma API (future)
│ │ │ └── index.ts # Provider 导出
│ │ │
│ │ ├── components/ # 组件
│ │ │ ├── common/ # 通用组件
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── Input.tsx
│ │ │ │ ├── Card.tsx
│ │ │ │ ├── Modal.tsx
│ │ │ │ ├── LoadingSpinner.tsx
│ │ │ │ └── ErrorBoundary.tsx
│ │ │ │
│ │ │ ├── layout/ # 布局组件
│ │ │ │ ├── Layout.tsx # 主布局
│ │ │ │ ├── Header.tsx # 顶部导航
│ │ │ │ ├── Sidebar.tsx # 侧边栏
│ │ │ │ └── Footer.tsx # 底部
│ │ │ │
│ │ │ ├── auth/ # 认证组件
│ │ │ │ ├── AccountingProviderSelect.tsx # 会计系统选择
│ │ │ │ ├── ProviderConnect.tsx # 通用连接组件
│ │ │ │ ├── LoginForm.tsx
│ │ │ │ └── ProtectedRoute.tsx
│ │ │ │
│ │ │ ├── upload/ # 上传组件
│ │ │ │ ├── FileUpload.tsx # 文件上传区域
│ │ │ │ ├── UploadProgress.tsx # 上传进度
│ │ │ │ ├── DragDropZone.tsx # 拖放区域
│ │ │ │ └── ProviderSelector.tsx # 目标会计系统选择
│ │ │ │
│ │ │ ├── invoice/ # 发票组件
│ │ │ │ ├── InvoiceCard.tsx # 发票卡片
│ │ │ │ ├── InvoiceList.tsx # 发票列表
│ │ │ │ ├── InvoicePreview.tsx # 发票预览
│ │ │ │ ├── InvoiceForm.tsx # 发票编辑表单
│ │ │ │ └── InvoiceStatus.tsx # 状态显示
│ │ │ │
│ │ │ ├── supplier/ # 供应商组件
│ │ │ │ ├── SupplierMatch.tsx # 供应商匹配
│ │ │ │ ├── SupplierSelect.tsx # 供应商选择
│ │ │ │ └── SupplierCreate.tsx # 创建供应商
│ │ │ │
│ │ │ └── voucher/ # 凭证组件
│ │ │ ├── VoucherPreview.tsx # 凭证预览
│ │ │ ├── AccountSelect.tsx # 科目选择
│ │ │ └── VoucherRows.tsx # 凭证行
│ │ │
│ │ ├── hooks/ # 自定义 Hooks
│ │ │ ├── useAuth.ts # 认证 Hook
│ │ │ ├── useInvoices.ts # 发票数据 Hook
│ │ │ ├── useSuppliers.ts # 供应商数据 Hook
│ │ │ ├── useUpload.ts # 上传 Hook
│ │ │ ├── useAccounting.ts # 会计系统连接 Hook (通用)
│ │ │ ├── useProviders.ts # Provider 列表 Hook
│ │ │ └── useToast.ts # 通知 Hook
│ │ │
│ │ ├── stores/ # 状态管理 (Zustand)
│ │ │ ├── authStore.ts # 认证状态
│ │ │ ├── invoiceStore.ts # 发票状态
│ │ │ ├── accountingStore.ts # 会计系统连接状态
│ │ │ └── uiStore.ts # UI 状态
│ │ │
│ │ ├── types/ # TypeScript 类型
│ │ │ ├── auth.ts
│ │ │ ├── invoice.ts
│ │ │ ├── supplier.ts
│ │ │ ├── voucher.ts
│ │ │ ├── accounting.ts # 会计系统类型
│ │ │ └── api.ts
│ │ │
│ │ ├── utils/ # 工具函数
│ │ │ ├── formatters.ts # 格式化
│ │ │ ├── validators.ts # 验证
│ │ │ └── constants.ts # 常量
│ │ │
│ │ └── pages/ # 页面组件
│ │ ├── Home.tsx # 首页/仪表盘
│ │ ├── Login.tsx # 登录页
│ │ ├── Connect.tsx # 会计系统连接页
│ │ ├── Upload.tsx # 上传页
│ │ ├── Review.tsx # 审核页
│ │ ├── History.tsx # 历史记录
│ │ ├── Settings.tsx # 设置页
│ │ └── NotFound.tsx # 404
│ │
│ └── public/ # 静态资源
│ ├── logo.svg
│ └── favicon.ico
├── infrastructure/ # 基础设施
│ ├── terraform/ # Terraform 配置
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ ├── backend.tf
│ │ └── modules/ # 模块
│ │ ├── database/
│ │ ├── cache/
│ │ ├── storage/
│ │ └── container_apps/
│ │
│ ├── azure/ # Azure 特定配置
│ │ ├── bicep/ # Bicep 模板
│ │ └── arm/ # ARM 模板
│ │
│ └── scripts/ # 部署脚本
│ ├── deploy.sh
│ ├── setup-local.sh
│ └── migrate.sh
├── docs/ # 文档
│ ├── development/ # 开发文档
│ ├── api/ # API 文档
│ ├── deployment/ # 部署文档
│ └── providers/ # Provider 开发文档 (新增)
│ ├── README.md # Provider 开发指南
│ ├── interface.md # 接口规范
│ └── examples/ # 示例代码
└── scripts/ # 实用脚本
├── setup.sh # 项目初始化
├── dev-start.sh # 启动开发环境
├── test.sh # 运行测试
├── lint.sh # 代码检查
└── add-provider.sh # 添加新 Provider 脚手架 (新增)
```
---
## 目录说明
### Frontend
- **api/**: API 客户端封装
- `accounting.ts`: 通用会计系统 API
- `providers/`: Provider 特定 API 实现
- **components/**: 按功能分组的 React 组件
- `auth/AccountingProviderSelect.tsx`: 会计系统选择组件
- `auth/ProviderConnect.tsx`: 通用连接组件
- `upload/ProviderSelector.tsx`: 上传时选择目标会计系统
- **hooks/**: 自定义 React Hooks
- `useAccounting.ts`: 通用会计系统连接 Hook
- `useProviders.ts`: 获取支持的 Provider 列表
- **stores/**: Zustand 状态管理
- `accountingStore.ts`: 会计系统连接状态
### Backend (.NET)
- **InvoiceMaster.API/**: Web API 入口
- `Controllers/`: API 控制器
- `Middleware/`: 中间件
- `appsettings.json`: 配置
- **InvoiceMaster.Core/**: 领域层
- `Entities/`: 领域实体 (Invoice, AccountingConnection)
- `Events/`: 领域事件 (用于审计)
- `Interfaces/`: 领域接口
- `ValueObjects/`: 值对象
- **InvoiceMaster.Application/**: 应用层 (CQRS)
- `Commands/`: 命令处理器 (MediatR)
- `Queries/`: 查询处理器
- `DTOs/`: 数据传输对象
- `Mappings/`: AutoMapper 配置
- **InvoiceMaster.Infrastructure/**: 基础设施层
- `Data/`: EF Core DbContext, 配置, 迁移
- `Repositories/`: 仓储实现
- `Services/`: 外部服务实现
- `Identity/`: ASP.NET Core Identity
- **InvoiceMaster.Integrations/**: 集成层
- `Accounting/`: 会计系统集成
- `IAccountingSystem.cs`: 接口
- `AccountingSystemFactory.cs`: 工厂
- `Providers/`: Provider 实现
- `Fortnox/`: Fortnox Provider
- `Visma/`: Visma Provider (future)
### Infrastructure
- **terraform/**: 基础设施即代码
- **scripts/**: 部署和运维脚本
### Docs
- **providers/**: Provider 开发文档 (新增)
- 如何添加新的会计系统 Provider
- 接口规范
- 示例代码
---
## 命名规范
### 文件命名
- **React 组件**: PascalCase (e.g., `InvoiceCard.tsx`)
- **Hooks**: camelCase with `use` prefix (e.g., `useAccounting.ts`)
- **工具函数**: camelCase (e.g., `formatters.ts`)
- **C# 类**: PascalCase (e.g., `InvoiceService.cs`)
- **C# 接口**: PascalCase with `I` prefix (e.g., `IAccountingSystem.cs`)
- **测试文件**: `{ClassName}Tests.cs` (e.g., `InvoiceServiceTests.cs`)
- **Provider 实现**: `{ProviderName}Provider.cs` (e.g., `FortnoxProvider.cs`)
### 代码规范
- **TypeScript**: 严格模式,显式返回类型
- **C#**: C# 12 特性, 使用 `record``required`, StyleCop 规则
- **CSS**: TailwindCSS 工具类优先
- **Git**: Conventional commits
---
## 添加新 Provider 的目录变更
当添加新的会计系统 Provider 时,需要创建/修改以下文件:
### Backend
```
backend/src/InvoiceMaster.Integrations/Accounting/Providers/
├── {ProviderName}/
│ ├── {ProviderName}Provider.cs # 新 Provider 实现
│ ├── {ProviderName}AuthClient.cs # OAuth 客户端
│ └── {ProviderName}ApiClient.cs # API 客户端
backend/src/InvoiceMaster.API/
├── appsettings.json # 添加 Provider 配置
└── Extensions/
└── AccountingServiceExtensions.cs # 注册 Provider
```
### Frontend
```
frontend/src/api/providers/
├── index.ts # 导出 Provider API
├── {provider}.ts # 新 Provider API 客户端 (可选)
frontend/src/types/accounting.ts # 添加 Provider 类型
```
### Docs
```
docs/providers/
├── {provider}.md # 新 Provider 文档
└── examples/
└── {provider}_example.py
```
---
**文档历史:**
| 版本 | 日期 | 作者 | 变更 |
|------|------|------|------|
| 3.0 | 2026-02-03 | Claude Code | 重构为 .NET 8 + 轻量级 DDD |
| 2.0 | 2026-02-03 | Claude Code | 添加多会计系统集成层 |
| 1.0 | 2026-02-03 | Claude Code | 初始版本 |

78
README.md Normal file
View File

@@ -0,0 +1,78 @@
# Invoice Master
Multi-accounting system invoice processing platform supporting Fortnox, Visma, Hogia, and more.
## Tech Stack
- **Backend**: .NET 8 + ASP.NET Core + EF Core + PostgreSQL
- **Frontend**: React 18 + TypeScript + Vite + TailwindCSS
- **Infrastructure**: Docker + Azure
## Quick Start
### Prerequisites
- .NET 8 SDK
- Node.js 18+
- Docker & Docker Compose
### Development
```bash
# Clone the repository
git clone <repo-url>
cd invoice-master
# Start infrastructure services
docker-compose up -d postgres redis
# Run backend
cd backend
dotnet restore
dotnet run --project src/InvoiceMaster.API
# Run frontend (in another terminal)
cd frontend
npm install
npm run dev
```
### Docker Compose (Full Stack)
```bash
docker-compose up -d
```
Services:
- Backend API: http://localhost:5000
- Frontend: http://localhost:5173
- Swagger UI: http://localhost:5000/swagger
## Project Structure
```
├── backend/ # .NET Backend
│ ├── src/
│ │ ├── InvoiceMaster.API/ # Web API
│ │ ├── InvoiceMaster.Core/ # Domain layer
│ │ ├── InvoiceMaster.Application/ # Application layer
│ │ ├── InvoiceMaster.Infrastructure/# Infrastructure layer
│ │ └── InvoiceMaster.Integrations/ # Accounting providers
│ └── tests/
├── frontend/ # React Frontend
│ └── src/
├── docs/ # Documentation
└── docker-compose.yml # Local development
```
## Documentation
- [Architecture](ARCHITECTURE.md)
- [API Design](API_DESIGN.md)
- [Database Schema](DATABASE_SCHEMA.md)
- [Development Plan](DEVELOPMENT_PLAN.md)
- [Deployment Guide](DEPLOYMENT_GUIDE.md)
## License
MIT

106
backend/.env.example Normal file
View File

@@ -0,0 +1,106 @@
# Backend Environment Variables
# Copy this file to .env and fill in your values
# ==========================================
# Application Configuration
# ==========================================
APP_NAME=Fortnox Invoice Integration
APP_ENV=development
DEBUG=true
SECRET_KEY=change-this-to-a-random-secret-key-in-production
# ==========================================
# Server Configuration
# ==========================================
HOST=0.0.0.0
PORT=8000
# ==========================================
# Database Configuration
# ==========================================
# Format: postgresql://user:password@host:port/database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/fortnox_invoice
# For async SQLAlchemy
ASYNC_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/fortnox_invoice
# Database pool settings
DB_POOL_SIZE=5
DB_MAX_OVERFLOW=10
DB_POOL_TIMEOUT=30
# ==========================================
# Redis Configuration
# ==========================================
REDIS_URL=redis://localhost:6379/0
REDIS_PASSWORD=
# ==========================================
# Azure Blob Storage
# ==========================================
# Get this from Azure Portal > Storage Account > Access Keys
AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=xxx;AccountKey=xxx;EndpointSuffix=core.windows.net
AZURE_STORAGE_CONTAINER=documents
AZURE_STORAGE_ACCOUNT_NAME=
AZURE_STORAGE_ACCOUNT_KEY=
# ==========================================
# Fortnox OAuth Configuration
# ==========================================
# Get these from Fortnox Developer Portal
FORTNOX_CLIENT_ID=your-fortnox-client-id
FORTNOX_CLIENT_SECRET=your-fortnox-client-secret
FORTNOX_REDIRECT_URI=http://localhost:5173/fortnox/callback
FORTNOX_AUTH_URL=https://apps.fortnox.se/oauth-v1/auth
FORTNOX_TOKEN_URL=https://apps.fortnox.se/oauth-v1/token
FORTNOX_API_BASE_URL=https://api.fortnox.se/3
# ==========================================
# Invoice Master OCR API
# ==========================================
# URL of your existing invoice-master API
OCR_API_URL=http://localhost:8000/api/v1
OCR_API_KEY=your-ocr-api-key
OCR_TIMEOUT=60
# ==========================================
# JWT Configuration
# ==========================================
JWT_SECRET_KEY=your-jwt-secret-key-min-32-characters-long
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=15
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
# ==========================================
# Encryption Configuration
# ==========================================
# 32-byte base64 encoded key for AES-256 encryption
ENCRYPTION_KEY=your-32-byte-encryption-key-base64-encoded=
# ==========================================
# Email Configuration (Optional)
# ==========================================
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=noreply@example.com
# ==========================================
# Logging Configuration
# ==========================================
LOG_LEVEL=INFO
LOG_FORMAT=json
# ==========================================
# Monitoring (Optional)
# ==========================================
APPLICATIONINSIGHTS_CONNECTION_STRING=
SENTRY_DSN=
# ==========================================
# Feature Flags
# ==========================================
ENABLE_AUTO_SUPPLIER_CREATE=false
ENABLE_PDF_ATTACHMENT=true
MAX_FILE_SIZE_MB=10
ALLOWED_FILE_TYPES=pdf,jpg,jpeg,png

View File

@@ -0,0 +1,18 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CodeAnalysisRuleSet>$(MSBuildThisFileDirectory)CodeAnalysis.ruleset</CodeAnalysisRuleSet>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<LangVersion>12.0</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>

15
backend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet build -c Release --no-restore
RUN dotnet publish src/InvoiceMaster.API/InvoiceMaster.API.csproj -c Release -o /app/publish --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "InvoiceMaster.API.dll"]

70
backend/InvoiceMaster.sln Normal file
View File

@@ -0,0 +1,70 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{8A4623CB-AB3F-4A20-8A6E-3A33B65D6F5A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvoiceMaster.API", "src\InvoiceMaster.API\InvoiceMaster.API.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvoiceMaster.Core", "src\InvoiceMaster.Core\InvoiceMaster.Core.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F23456789012}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvoiceMaster.Application", "src\InvoiceMaster.Application\InvoiceMaster.Application.csproj", "{C3D4E5F6-A7B8-9012-CDEF-345678901234}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvoiceMaster.Infrastructure", "src\InvoiceMaster.Infrastructure\InvoiceMaster.Infrastructure.csproj", "{D4E5F6A7-B8C9-0123-DEFA-456789012345}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvoiceMaster.Integrations", "src\InvoiceMaster.Integrations\InvoiceMaster.Integrations.csproj", "{E5F6A7B8-C9D0-1234-EFAB-567890123456}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F6A7B8C9-D0E1-2345-FABC-678901234567}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvoiceMaster.UnitTests", "tests\InvoiceMaster.UnitTests\InvoiceMaster.UnitTests.csproj", "{A7B8C9D0-E1F2-3456-ABCD-789012345678}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvoiceMaster.IntegrationTests", "tests\InvoiceMaster.IntegrationTests\InvoiceMaster.IntegrationTests.csproj", "{B8C9D0E1-F2A3-4567-BCDE-890123456789}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.Build.0 = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-345678901234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-345678901234}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-345678901234}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-345678901234}.Release|Any CPU.Build.0 = Release|Any CPU
{D4E5F6A7-B8C9-0123-DEFA-456789012345}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEFA-456789012345}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEFA-456789012345}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D4E5F6A7-B8C9-0123-DEFA-456789012345}.Release|Any CPU.Build.0 = Release|Any CPU
{E5F6A7B8-C9D0-1234-EFAB-567890123456}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EFAB-567890123456}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EFAB-567890123456}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E5F6A7B8-C9D0-1234-EFAB-567890123456}.Release|Any CPU.Build.0 = Release|Any CPU
{A7B8C9D0-E1F2-3456-ABCD-789012345678}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A7B8C9D0-E1F2-3456-ABCD-789012345678}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A7B8C9D0-E1F2-3456-ABCD-789012345678}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A7B8C9D0-E1F2-3456-ABCD-789012345678}.Release|Any CPU.Build.0 = Release|Any CPU
{B8C9D0E1-F2A3-4567-BCDE-890123456789}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8C9D0E1-F2A3-4567-BCDE-890123456789}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8C9D0E1-F2A3-4567-BCDE-890123456789}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8C9D0E1-F2A3-4567-BCDE-890123456789}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {8A4623CB-AB3F-4A20-8A6E-3A33B65D6F5A}
{B2C3D4E5-F6A7-8901-BCDE-F23456789012} = {8A4623CB-AB3F-4A20-8A6E-3A33B65D6F5A}
{C3D4E5F6-A7B8-9012-CDEF-345678901234} = {8A4623CB-AB3F-4A20-8A6E-3A33B65D6F5A}
{D4E5F6A7-B8C9-0123-DEFA-456789012345} = {8A4623CB-AB3F-4A20-8A6E-3A33B65D6F5A}
{E5F6A7B8-C9D0-1234-EFAB-567890123456} = {8A4623CB-AB3F-4A20-8A6E-3A33B65D6F5A}
{A7B8C9D0-E1F2-3456-ABCD-789012345678} = {F6A7B8C9-D0E1-2345-FABC-678901234567}
{B8C9D0E1-F2A3-4567-BCDE-890123456789} = {F6A7B8C9-D0E1-2345-FABC-678901234567}
EndGlobalSection
EndGlobal

44
backend/README.md Normal file
View File

@@ -0,0 +1,44 @@
# Invoice Master - Backend
## Project Structure
This backend follows Clean Architecture with the following projects:
- **InvoiceMaster.Core** - Domain entities, interfaces, value objects
- **InvoiceMaster.Application** - Business logic, CQRS commands/queries
- **InvoiceMaster.Infrastructure** - EF Core, repositories, external services
- **InvoiceMaster.Integrations** - Accounting system providers
- **InvoiceMaster.API** - Web API entry point
## Getting Started
### Prerequisites
- .NET 8 SDK
- PostgreSQL 15+
- Redis 7+ (optional)
### Running Locally
```bash
# Restore dependencies
dotnet restore
# Run database migrations
cd src/InvoiceMaster.Infrastructure
dotnet ef database update --startup-project ../InvoiceMaster.API
# Run the API
cd ../InvoiceMaster.API
dotnet run
```
### Running Tests
```bash
dotnet test
```
## Environment Variables
See `src/InvoiceMaster.API/appsettings.Development.json` for configuration.

6
backend/global.json Normal file
View File

@@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.100",
"rollForward": "latestFeature"
}
}

View File

@@ -0,0 +1,153 @@
using InvoiceMaster.Application.DTOs;
using InvoiceMaster.Core.Entities;
using InvoiceMaster.Core.Interfaces;
using InvoiceMaster.Integrations.Accounting;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace InvoiceMaster.API.Controllers;
[ApiController]
[Route("api/v1/accounting")]
[Authorize]
public class AccountingController : ControllerBase
{
private readonly IAccountingSystemFactory _factory;
private readonly IRepository<AccountingConnection> _connectionRepository;
private readonly IRepository<User> _userRepository;
public AccountingController(
IAccountingSystemFactory factory,
IRepository<AccountingConnection> connectionRepository,
IRepository<User> userRepository)
{
_factory = factory;
_connectionRepository = connectionRepository;
_userRepository = userRepository;
}
[HttpGet("providers")]
public IActionResult GetProviders()
{
var userId = GetUserId();
var connections = _connectionRepository.GetAllAsync().Result
.Where(c => c.UserId == userId && c.IsActive)
.ToList();
var providers = new[]
{
new ProviderInfoDto("fortnox", "Fortnox", "Swedish accounting software", true,
connections.Any(c => c.Provider == "fortnox")),
new ProviderInfoDto("visma", "Visma eAccounting", "Nordic accounting software", false, false),
new ProviderInfoDto("hogia", "Hogia Smart", "Swedish accounting software", false, false)
};
return Ok(new { success = true, data = new { providers } });
}
[HttpGet("{provider}/auth/url")]
public IActionResult GetAuthUrl(string provider)
{
var redirectUri = $"{Request.Scheme}://{Request.Host}/api/v1/accounting/{provider}/auth/callback";
var state = Guid.NewGuid().ToString("N");
string authUrl = provider.ToLower() switch
{
"fortnox" => $"https://apps.fortnox.se/oauth-v1/auth?client_id=&redirect_uri={Uri.EscapeDataString(redirectUri)}&scope=supplier+voucher+account&state={state}",
_ => throw new NotSupportedException($"Provider '{provider}' is not supported")
};
return Ok(new
{
success = true,
data = new { provider, authorizationUrl = authUrl, state }
});
}
[HttpGet("{provider}/auth/callback")]
public async Task<IActionResult> AuthCallback(string provider, [FromQuery] string code, [FromQuery] string state)
{
var accounting = _factory.Create(provider);
var result = await accounting.AuthenticateAsync(code);
if (!result.Success)
{
return BadRequest(new { success = false, error = result.ErrorMessage });
}
var userId = GetUserId();
var connection = AccountingConnection.Create(
userId,
provider,
result.AccessToken ?? string.Empty,
result.RefreshToken ?? string.Empty,
result.ExpiresAt ?? DateTime.UtcNow.AddHours(1),
result.Scope,
result.CompanyInfo?.Name,
result.CompanyInfo?.OrganisationNumber);
await _connectionRepository.AddAsync(connection);
return Ok(new
{
success = true,
data = new
{
provider,
connected = true,
companyName = result.CompanyInfo?.Name,
companyOrgNumber = result.CompanyInfo?.OrganisationNumber,
connectedAt = connection.CreatedAt
}
});
}
[HttpGet("connections")]
public async Task<IActionResult> GetConnections()
{
var userId = GetUserId();
var connections = (await _connectionRepository.GetAllAsync())
.Where(c => c.UserId == userId && c.IsActive)
.Select(c => new ConnectionDto(
c.Provider,
true,
c.CompanyName,
c.CompanyOrgNumber,
c.Scope?.Split(' ').ToList(),
c.ExpiresAt,
new ConnectionSettingsDto(
c.DefaultVoucherSeries,
c.DefaultAccountCode,
c.AutoAttachPdf,
c.AutoCreateSupplier)))
.ToList();
return Ok(new { success = true, data = new { connections } });
}
[HttpDelete("connections/{provider}")]
public async Task<IActionResult> Disconnect(string provider)
{
var userId = GetUserId();
var connections = await _connectionRepository.GetAllAsync();
var connection = connections.FirstOrDefault(c =>
c.UserId == userId &&
c.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase) &&
c.IsActive);
if (connection == null)
{
return NotFound(new { success = false, error = "Connection not found" });
}
connection.Deactivate();
return Ok(new { success = true });
}
private Guid GetUserId()
{
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return Guid.Parse(userId!);
}
}

View File

@@ -0,0 +1,108 @@
using InvoiceMaster.Application.Commands.Auth;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace InvoiceMaster.API.Controllers;
[ApiController]
[Route("api/v1/auth")]
public class AuthController : ControllerBase
{
private readonly IMediator _mediator;
public AuthController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost("register")]
[AllowAnonymous]
public async Task<IActionResult> Register([FromBody] RegisterCommand command)
{
var result = await _mediator.Send(command);
if (!result.Success)
{
return BadRequest(new
{
success = false,
error = new { code = "REGISTRATION_FAILED", message = result.ErrorMessage }
});
}
return Ok(new
{
success = true,
data = new
{
user = result.User,
tokens = result.Tokens
}
});
}
[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login([FromBody] LoginCommand command)
{
var result = await _mediator.Send(command);
if (!result.Success)
{
return Unauthorized(new
{
success = false,
error = new { code = "AUTHENTICATION_FAILED", message = result.ErrorMessage }
});
}
return Ok(new
{
success = true,
data = new
{
user = result.User,
tokens = result.Tokens
}
});
}
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenCommand command)
{
var result = await _mediator.Send(command);
if (result == null)
{
return Unauthorized(new
{
success = false,
error = new { code = "INVALID_REFRESH_TOKEN", message = "Invalid or expired refresh token" }
});
}
return Ok(new
{
success = true,
data = new { tokens = result }
});
}
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (userId == null || !Guid.TryParse(userId, out var guid))
{
return Unauthorized();
}
var command = new LogoutCommand(guid);
await _mediator.Send(command);
return Ok(new { success = true });
}
}

View File

@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc;
namespace InvoiceMaster.API.Controllers;
[ApiController]
[Route("api/v1/health")]
public class HealthController : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
return Ok(new
{
status = "healthy",
timestamp = DateTime.UtcNow.ToString("O"),
version = "1.0.0"
});
}
}

View File

@@ -0,0 +1,65 @@
using InvoiceMaster.Application.Commands.Invoices;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace InvoiceMaster.API.Controllers;
[ApiController]
[Route("api/v1/invoices")]
[Authorize]
public partial class InvoicesController : ControllerBase
{
[HttpPost("{id}/import")]
public async Task<IActionResult> ImportInvoice(Guid id, [FromBody] ImportInvoiceRequest? request)
{
var userId = GetUserId();
var command = new ImportInvoiceCommand(
id,
userId,
request?.CreateSupplier ?? false,
request?.SupplierData != null
? new AccountingSupplier(
request.SupplierData.Name,
request.SupplierData.Name,
request.SupplierData.OrganisationNumber)
: null);
var result = await _mediator.Send(command);
if (!result.Success)
{
return BadRequest(new { success = false, error = result.ErrorMessage });
}
return Ok(new { success = true, data = result.Data });
}
private Guid GetUserId()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return Guid.Parse(userId!);
}
}
public record ImportInvoiceRequest(
bool CreateSupplier = false,
SupplierDataRequest? SupplierData = null);
public record SupplierDataRequest(
string Name,
string OrganisationNumber);
public record AccountingSupplier(
string SupplierNumber,
string Name,
string? OrganisationNumber = null,
string? Address1 = null,
string? Postcode = null,
string? City = null,
string? Phone = null,
string? Email = null,
string? BankgiroNumber = null,
string? PlusgiroNumber = null);

View File

@@ -0,0 +1,67 @@
using InvoiceMaster.Application.Commands.Invoices;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace InvoiceMaster.API.Controllers;
[ApiController]
[Route("api/v1/invoices")]
[Authorize]
public class InvoicesController : ControllerBase
{
private readonly IMediator _mediator;
public InvoicesController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
[Consumes("multipart/form-data")]
public async Task<IActionResult> UploadInvoice([FromForm] UploadInvoiceRequest request)
{
var userId = GetUserId();
if (request.File == null || request.File.Length == 0)
{
return BadRequest(new { success = false, error = "No file uploaded" });
}
if (!request.File.ContentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
{
return BadRequest(new { success = false, error = "Only PDF files are supported" });
}
var command = new UploadInvoiceCommand(
userId,
request.Provider,
request.File.FileName,
request.File.OpenReadStream(),
request.File.Length,
request.File.ContentType);
var result = await _mediator.Send(command);
if (!result.Success)
{
return BadRequest(new { success = false, error = result.ErrorMessage });
}
return Ok(new { success = true, data = result.Invoice });
}
private Guid GetUserId()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return Guid.Parse(userId!);
}
}
public class UploadInvoiceRequest
{
public IFormFile? File { get; set; }
public string Provider { get; set; } = "fortnox";
public bool AutoProcess { get; set; } = false;
}

View File

@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
namespace InvoiceMaster.API.Extensions;
public static class AuthenticationExtensions
{
public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration)
{
var jwtSettings = configuration.GetSection("Jwt");
var secretKey = jwtSettings["SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey is not configured");
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings["Issuer"],
ValidAudience = jwtSettings["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)),
ClockSkew = TimeSpan.Zero
};
});
return services;
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<AssemblyName>InvoiceMaster.API</AssemblyName>
<RootNamespace>InvoiceMaster.API</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InvoiceMaster.Application\InvoiceMaster.Application.csproj" />
<ProjectReference Include="..\InvoiceMaster.Infrastructure\InvoiceMaster.Infrastructure.csproj" />
<ProjectReference Include="..\InvoiceMaster.Integrations\InvoiceMaster.Integrations.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,77 @@
using Microsoft.AspNetCore.Mvc;
using System.Net;
namespace InvoiceMaster.API.Middleware;
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "An unhandled exception occurred");
await HandleExceptionAsync(context, ex);
}
}
private static Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
var (statusCode, errorCode, message) = exception switch
{
UnauthorizedAccessException _ => (
(int)HttpStatusCode.Unauthorized,
"UNAUTHORIZED",
"Authentication required"
),
InvalidOperationException _ => (
(int)HttpStatusCode.BadRequest,
"INVALID_OPERATION",
exception.Message
),
KeyNotFoundException _ => (
(int)HttpStatusCode.NotFound,
"NOT_FOUND",
"Resource not found"
),
_ => (
(int)HttpStatusCode.InternalServerError,
"INTERNAL_ERROR",
"An unexpected error occurred"
)
};
context.Response.StatusCode = statusCode;
var response = new
{
success = false,
error = new
{
code = errorCode,
message
},
meta = new
{
request_id = context.TraceIdentifier,
timestamp = DateTime.UtcNow.ToString("O")
}
};
return context.Response.WriteAsJsonAsync(response);
}
}

View File

@@ -0,0 +1,99 @@
using InvoiceMaster.API.Extensions;
using InvoiceMaster.Application;
using InvoiceMaster.Infrastructure.Data;
using InvoiceMaster.Infrastructure.Extensions;
using InvoiceMaster.Integrations.Extensions;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog
builder.Host.UseSerilog((context, configuration) =>
configuration.ReadFrom.Configuration(context.Configuration));
// Add services to the container
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new()
{
Title = "Invoice Master API",
Version = "v1",
Description = "Multi-accounting system invoice processing platform"
});
// Add JWT authentication to Swagger
options.AddSecurityDefinition("Bearer", new()
{
Description = "JWT Authorization header using the Bearer scheme. Example: \"Bearer {token}\"",
Name = "Authorization",
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey,
Scheme = "Bearer"
});
options.AddSecurityRequirement(new()
{
{
new()
{
Reference = new()
{
Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
});
// Add CORS
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
{
policy.WithOrigins(
builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
?? ["http://localhost:5173"])
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
// Add application services
builder.Services.AddApplicationServices();
builder.Services.AddInfrastructureServices(builder.Configuration);
builder.Services.AddIntegrationServices(builder.Configuration);
// Add JWT authentication
builder.Services.AddJwtAuthentication(builder.Configuration);
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseSerilogRequestLogging();
app.UseHttpsRedirection();
app.UseCors("AllowFrontend");
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<InvoiceMaster.API.Middleware.ExceptionHandlingMiddleware>();
app.MapControllers();
// Ensure database is created and migrated
using (var scope = app.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await context.Database.MigrateAsync();
}
app.Run();

View File

@@ -0,0 +1,20 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Information",
"System": "Information"
}
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=invoice_master_dev;Username=postgres;Password=postgres"
}
}

View File

@@ -0,0 +1,57 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Serilog": {
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console"
},
{
"Name": "File",
"Args": {
"path": "logs/log-.txt",
"rollingInterval": "Day"
}
}
]
},
"AllowedHosts": "*",
"Cors": {
"AllowedOrigins": ["http://localhost:5173", "https://localhost:5173"]
},
"Jwt": {
"SecretKey": "your-super-secret-key-min-32-chars-long",
"Issuer": "InvoiceMaster",
"Audience": "InvoiceMaster.Client",
"AccessTokenExpirationMinutes": 15,
"RefreshTokenExpirationDays": 7
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=invoice_master;Username=postgres;Password=postgres"
},
"AzureStorage": {
"ConnectionString": "",
"ContainerName": "documents"
},
"Ocr": {
"ApiUrl": "http://localhost:8000/api/v1",
"ApiKey": ""
},
"Fortnox": {
"ClientId": "",
"ClientSecret": "",
"RedirectUri": "http://localhost:5173/accounting/fortnox/callback"
}
}

View File

@@ -0,0 +1,4 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")]

View File

@@ -0,0 +1,22 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("InvoiceMaster.API")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("InvoiceMaster.API")]
[assembly: System.Reflection.AssemblyTitleAttribute("InvoiceMaster.API")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// Generated by the MSBuild WriteCodeFragment class.

View File

@@ -0,0 +1 @@
0b17e756d606e87e18da4b7ff18325a40b19e253e4e72e12aaf90ad0cae31d74

View File

@@ -0,0 +1,23 @@
is_global = true
build_property.TargetFramework = net8.0
build_property.TargetFrameworkIdentifier = .NETCoreApp
build_property.TargetFrameworkVersion = v8.0
build_property.TargetPlatformMinVersion =
build_property.UsingMicrosoftNETSdkWeb = true
build_property.ProjectTypeGuids =
build_property.InvariantGlobalization =
build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = InvoiceMaster.API
build_property.RootNamespace = InvoiceMaster.API
build_property.ProjectDir = C:\Users\yaoji\git\ColaCoder\accounting-system\backend\src\InvoiceMaster.API\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.RazorLangVersion = 8.0
build_property.SupportLocalizedComponentNames =
build_property.GenerateRazorMetadataSourceChecksumAttributes =
build_property.MSBuildProjectDirectory = C:\Users\yaoji\git\ColaCoder\accounting-system\backend\src\InvoiceMaster.API
build_property._RazorSourceGeneratorDebug =
build_property.EffectiveAnalysisLevelStyle = 8.0
build_property.EnableCodeStyleSeverity =

View File

@@ -0,0 +1,17 @@
// <auto-generated/>
global using Microsoft.AspNetCore.Builder;
global using Microsoft.AspNetCore.Hosting;
global using Microsoft.AspNetCore.Http;
global using Microsoft.AspNetCore.Routing;
global using Microsoft.Extensions.Configuration;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Hosting;
global using Microsoft.Extensions.Logging;
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Net.Http;
global using System.Net.Http.Json;
global using System.Threading;
global using System.Threading.Tasks;

View File

@@ -0,0 +1,490 @@
{
"format": 1,
"restore": {
"C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj": {}
},
"projects": {
"C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"projectName": "InvoiceMaster.API",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"packagesPath": "C:\\Users\\yaoji\\.nuget\\packages\\",
"outputPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\yaoji\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
"originalTargetFrameworks": [
"net8.0"
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"https://api.nuget.org/v3/index.json": {},
"https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json": {}
},
"frameworks": {
"net8.0": {
"targetAlias": "net8.0",
"projectReferences": {
"C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Application\\InvoiceMaster.Application.csproj": {
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Application\\InvoiceMaster.Application.csproj"
},
"C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Infrastructure\\InvoiceMaster.Infrastructure.csproj": {
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Infrastructure\\InvoiceMaster.Infrastructure.csproj"
},
"C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Integrations\\InvoiceMaster.Integrations.csproj": {
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Integrations\\InvoiceMaster.Integrations.csproj"
}
}
}
},
"warningProperties": {
"allWarningsAsErrors": true,
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.100"
},
"frameworks": {
"net8.0": {
"targetAlias": "net8.0",
"dependencies": {
"Serilog.AspNetCore": {
"target": "Package",
"version": "[8.0.0, )"
},
"Serilog.Sinks.Console": {
"target": "Package",
"version": "[5.0.1, )"
},
"Serilog.Sinks.File": {
"target": "Package",
"version": "[5.0.0, )"
},
"StyleCop.Analyzers": {
"include": "Runtime, Build, Native, ContentFiles, Analyzers",
"suppressParent": "All",
"target": "Package",
"version": "[1.2.0-beta.556, )"
},
"Swashbuckle.AspNetCore": {
"target": "Package",
"version": "[6.5.0, )"
}
},
"imports": [
"net461",
"net462",
"net47",
"net471",
"net472",
"net48",
"net481"
],
"assetTargetFallback": true,
"warn": true,
"frameworkReferences": {
"Microsoft.AspNetCore.App": {
"privateAssets": "none"
},
"Microsoft.NETCore.App": {
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.102/PortableRuntimeIdentifierGraph.json"
}
}
},
"C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Application\\InvoiceMaster.Application.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Application\\InvoiceMaster.Application.csproj",
"projectName": "InvoiceMaster.Application",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Application\\InvoiceMaster.Application.csproj",
"packagesPath": "C:\\Users\\yaoji\\.nuget\\packages\\",
"outputPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Application\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\yaoji\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
"originalTargetFrameworks": [
"net8.0"
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"https://api.nuget.org/v3/index.json": {},
"https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json": {}
},
"frameworks": {
"net8.0": {
"targetAlias": "net8.0",
"projectReferences": {
"C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Core\\InvoiceMaster.Core.csproj": {
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Core\\InvoiceMaster.Core.csproj"
}
}
}
},
"warningProperties": {
"allWarningsAsErrors": true,
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.100"
},
"frameworks": {
"net8.0": {
"targetAlias": "net8.0",
"dependencies": {
"AutoMapper": {
"target": "Package",
"version": "[12.0.1, )"
},
"FluentValidation": {
"target": "Package",
"version": "[11.8.1, )"
},
"MediatR": {
"target": "Package",
"version": "[12.2.0, )"
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"target": "Package",
"version": "[8.0.0, )"
},
"StyleCop.Analyzers": {
"include": "Runtime, Build, Native, ContentFiles, Analyzers",
"suppressParent": "All",
"target": "Package",
"version": "[1.2.0-beta.556, )"
}
},
"imports": [
"net461",
"net462",
"net47",
"net471",
"net472",
"net48",
"net481"
],
"assetTargetFallback": true,
"warn": true,
"frameworkReferences": {
"Microsoft.NETCore.App": {
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.102/PortableRuntimeIdentifierGraph.json"
}
}
},
"C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Core\\InvoiceMaster.Core.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Core\\InvoiceMaster.Core.csproj",
"projectName": "InvoiceMaster.Core",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Core\\InvoiceMaster.Core.csproj",
"packagesPath": "C:\\Users\\yaoji\\.nuget\\packages\\",
"outputPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Core\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\yaoji\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
"originalTargetFrameworks": [
"net8.0"
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"https://api.nuget.org/v3/index.json": {},
"https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json": {}
},
"frameworks": {
"net8.0": {
"targetAlias": "net8.0",
"projectReferences": {}
}
},
"warningProperties": {
"allWarningsAsErrors": true,
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.100"
},
"frameworks": {
"net8.0": {
"targetAlias": "net8.0",
"dependencies": {
"StyleCop.Analyzers": {
"include": "Runtime, Build, Native, ContentFiles, Analyzers",
"suppressParent": "All",
"target": "Package",
"version": "[1.2.0-beta.556, )"
}
},
"imports": [
"net461",
"net462",
"net47",
"net471",
"net472",
"net48",
"net481"
],
"assetTargetFallback": true,
"warn": true,
"frameworkReferences": {
"Microsoft.NETCore.App": {
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.102/PortableRuntimeIdentifierGraph.json"
}
}
},
"C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Infrastructure\\InvoiceMaster.Infrastructure.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Infrastructure\\InvoiceMaster.Infrastructure.csproj",
"projectName": "InvoiceMaster.Infrastructure",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Infrastructure\\InvoiceMaster.Infrastructure.csproj",
"packagesPath": "C:\\Users\\yaoji\\.nuget\\packages\\",
"outputPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Infrastructure\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\yaoji\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
"originalTargetFrameworks": [
"net8.0"
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"https://api.nuget.org/v3/index.json": {},
"https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json": {}
},
"frameworks": {
"net8.0": {
"targetAlias": "net8.0",
"projectReferences": {
"C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Application\\InvoiceMaster.Application.csproj": {
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Application\\InvoiceMaster.Application.csproj"
}
}
}
},
"warningProperties": {
"allWarningsAsErrors": true,
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.100"
},
"frameworks": {
"net8.0": {
"targetAlias": "net8.0",
"dependencies": {
"Azure.Storage.Blobs": {
"target": "Package",
"version": "[12.19.1, )"
},
"Microsoft.AspNetCore.Identity.EntityFrameworkCore": {
"target": "Package",
"version": "[8.0.0, )"
},
"Microsoft.EntityFrameworkCore": {
"target": "Package",
"version": "[8.0.0, )"
},
"Microsoft.EntityFrameworkCore.Tools": {
"include": "Runtime, Build, Native, ContentFiles, Analyzers",
"suppressParent": "All",
"target": "Package",
"version": "[8.0.0, )"
},
"Microsoft.Extensions.Http": {
"target": "Package",
"version": "[8.0.0, )"
},
"Npgsql.EntityFrameworkCore.PostgreSQL": {
"target": "Package",
"version": "[8.0.0, )"
},
"Polly": {
"target": "Package",
"version": "[8.2.0, )"
},
"Polly.Extensions.Http": {
"target": "Package",
"version": "[3.0.0, )"
},
"StyleCop.Analyzers": {
"include": "Runtime, Build, Native, ContentFiles, Analyzers",
"suppressParent": "All",
"target": "Package",
"version": "[1.2.0-beta.556, )"
}
},
"imports": [
"net461",
"net462",
"net47",
"net471",
"net472",
"net48",
"net481"
],
"assetTargetFallback": true,
"warn": true,
"frameworkReferences": {
"Microsoft.NETCore.App": {
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.102/PortableRuntimeIdentifierGraph.json"
}
}
},
"C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Integrations\\InvoiceMaster.Integrations.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Integrations\\InvoiceMaster.Integrations.csproj",
"projectName": "InvoiceMaster.Integrations",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Integrations\\InvoiceMaster.Integrations.csproj",
"packagesPath": "C:\\Users\\yaoji\\.nuget\\packages\\",
"outputPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Integrations\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\yaoji\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
"originalTargetFrameworks": [
"net8.0"
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"https://api.nuget.org/v3/index.json": {},
"https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json": {}
},
"frameworks": {
"net8.0": {
"targetAlias": "net8.0",
"projectReferences": {
"C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Core\\InvoiceMaster.Core.csproj": {
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.Core\\InvoiceMaster.Core.csproj"
}
}
}
},
"warningProperties": {
"allWarningsAsErrors": true,
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.100"
},
"frameworks": {
"net8.0": {
"targetAlias": "net8.0",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": {
"target": "Package",
"version": "[8.0.0, )"
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"target": "Package",
"version": "[8.0.0, )"
},
"Microsoft.Extensions.Http": {
"target": "Package",
"version": "[8.0.0, )"
},
"Microsoft.Extensions.Logging.Abstractions": {
"target": "Package",
"version": "[8.0.0, )"
},
"StyleCop.Analyzers": {
"include": "Runtime, Build, Native, ContentFiles, Analyzers",
"suppressParent": "All",
"target": "Package",
"version": "[1.2.0-beta.556, )"
}
},
"imports": [
"net461",
"net462",
"net47",
"net471",
"net472",
"net48",
"net481"
],
"assetTargetFallback": true,
"warn": true,
"frameworkReferences": {
"Microsoft.NETCore.App": {
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.102/PortableRuntimeIdentifierGraph.json"
}
}
}
}
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\yaoji\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="C:\Users\yaoji\.nuget\packages\" />
<SourceRoot Include="C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages\" />
</ItemGroup>
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<Import Project="$(NuGetPackageRoot)microsoft.extensions.apidescription.server\6.0.5\build\Microsoft.Extensions.ApiDescription.Server.props" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.apidescription.server\6.0.5\build\Microsoft.Extensions.ApiDescription.Server.props')" />
<Import Project="$(NuGetPackageRoot)swashbuckle.aspnetcore\6.5.0\build\Swashbuckle.AspNetCore.props" Condition="Exists('$(NuGetPackageRoot)swashbuckle.aspnetcore\6.5.0\build\Swashbuckle.AspNetCore.props')" />
<Import Project="$(NuGetPackageRoot)microsoft.entityframeworkcore\8.0.0\buildTransitive\net8.0\Microsoft.EntityFrameworkCore.props" Condition="Exists('$(NuGetPackageRoot)microsoft.entityframeworkcore\8.0.0\buildTransitive\net8.0\Microsoft.EntityFrameworkCore.props')" />
</ImportGroup>
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<PkgMicrosoft_Extensions_ApiDescription_Server Condition=" '$(PkgMicrosoft_Extensions_ApiDescription_Server)' == '' ">C:\Users\yaoji\.nuget\packages\microsoft.extensions.apidescription.server\6.0.5</PkgMicrosoft_Extensions_ApiDescription_Server>
<PkgStyleCop_Analyzers_Unstable Condition=" '$(PkgStyleCop_Analyzers_Unstable)' == '' ">C:\Users\yaoji\.nuget\packages\stylecop.analyzers.unstable\1.2.0.556</PkgStyleCop_Analyzers_Unstable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<Import Project="$(NuGetPackageRoot)system.text.json\8.0.0\buildTransitive\net6.0\System.Text.Json.targets" Condition="Exists('$(NuGetPackageRoot)system.text.json\8.0.0\buildTransitive\net6.0\System.Text.Json.targets')" />
<Import Project="$(NuGetPackageRoot)microsoft.extensions.apidescription.server\6.0.5\build\Microsoft.Extensions.ApiDescription.Server.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.apidescription.server\6.0.5\build\Microsoft.Extensions.ApiDescription.Server.targets')" />
<Import Project="$(NuGetPackageRoot)microsoft.extensions.configuration.binder\8.0.0\buildTransitive\netstandard2.0\Microsoft.Extensions.Configuration.Binder.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.configuration.binder\8.0.0\buildTransitive\netstandard2.0\Microsoft.Extensions.Configuration.Binder.targets')" />
<Import Project="$(NuGetPackageRoot)microsoft.extensions.options\8.0.0\buildTransitive\net6.0\Microsoft.Extensions.Options.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.options\8.0.0\buildTransitive\net6.0\Microsoft.Extensions.Options.targets')" />
<Import Project="$(NuGetPackageRoot)microsoft.extensions.logging.abstractions\8.0.0\buildTransitive\net6.0\Microsoft.Extensions.Logging.Abstractions.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.logging.abstractions\8.0.0\buildTransitive\net6.0\Microsoft.Extensions.Logging.Abstractions.targets')" />
</ImportGroup>
</Project>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
{
"version": 2,
"dgSpecHash": "+5p9vl5fRd0=",
"success": false,
"projectFilePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"expectedPackageFiles": [
"C:\\Users\\yaoji\\.nuget\\packages\\automapper\\12.0.1\\automapper.12.0.1.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\azure.core\\1.36.0\\azure.core.1.36.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\azure.storage.blobs\\12.19.1\\azure.storage.blobs.12.19.1.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\azure.storage.common\\12.18.1\\azure.storage.common.12.18.1.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\fluentvalidation\\11.8.1\\fluentvalidation.11.8.1.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\mediatr\\12.2.0\\mediatr.12.2.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\mediatr.contracts\\2.0.1\\mediatr.contracts.2.0.1.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.aspnetcore.cryptography.internal\\8.0.0\\microsoft.aspnetcore.cryptography.internal.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.aspnetcore.cryptography.keyderivation\\8.0.0\\microsoft.aspnetcore.cryptography.keyderivation.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.aspnetcore.identity.entityframeworkcore\\8.0.0\\microsoft.aspnetcore.identity.entityframeworkcore.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.bcl.asyncinterfaces\\1.1.1\\microsoft.bcl.asyncinterfaces.1.1.1.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.csharp\\4.7.0\\microsoft.csharp.4.7.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.entityframeworkcore\\8.0.0\\microsoft.entityframeworkcore.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.entityframeworkcore.abstractions\\8.0.0\\microsoft.entityframeworkcore.abstractions.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.entityframeworkcore.analyzers\\8.0.0\\microsoft.entityframeworkcore.analyzers.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.entityframeworkcore.relational\\8.0.0\\microsoft.entityframeworkcore.relational.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.apidescription.server\\6.0.5\\microsoft.extensions.apidescription.server.6.0.5.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.caching.abstractions\\8.0.0\\microsoft.extensions.caching.abstractions.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.caching.memory\\8.0.0\\microsoft.extensions.caching.memory.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.configuration\\8.0.0\\microsoft.extensions.configuration.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.configuration.abstractions\\8.0.0\\microsoft.extensions.configuration.abstractions.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.configuration.binder\\8.0.0\\microsoft.extensions.configuration.binder.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.dependencyinjection\\8.0.0\\microsoft.extensions.dependencyinjection.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.dependencyinjection.abstractions\\8.0.0\\microsoft.extensions.dependencyinjection.abstractions.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.dependencymodel\\8.0.0\\microsoft.extensions.dependencymodel.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.diagnostics\\8.0.0\\microsoft.extensions.diagnostics.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.diagnostics.abstractions\\8.0.0\\microsoft.extensions.diagnostics.abstractions.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.fileproviders.abstractions\\8.0.0\\microsoft.extensions.fileproviders.abstractions.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.hosting.abstractions\\8.0.0\\microsoft.extensions.hosting.abstractions.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.http\\8.0.0\\microsoft.extensions.http.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.identity.core\\8.0.0\\microsoft.extensions.identity.core.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.identity.stores\\8.0.0\\microsoft.extensions.identity.stores.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.logging\\8.0.0\\microsoft.extensions.logging.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.logging.abstractions\\8.0.0\\microsoft.extensions.logging.abstractions.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.options\\8.0.0\\microsoft.extensions.options.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.options.configurationextensions\\8.0.0\\microsoft.extensions.options.configurationextensions.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.extensions.primitives\\8.0.0\\microsoft.extensions.primitives.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\microsoft.openapi\\1.2.3\\microsoft.openapi.1.2.3.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\npgsql\\8.0.0\\npgsql.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\npgsql.entityframeworkcore.postgresql\\8.0.0\\npgsql.entityframeworkcore.postgresql.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\polly\\8.2.0\\polly.8.2.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\polly.core\\8.2.0\\polly.core.8.2.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\polly.extensions.http\\3.0.0\\polly.extensions.http.3.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\serilog\\3.1.1\\serilog.3.1.1.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\serilog.aspnetcore\\8.0.0\\serilog.aspnetcore.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\serilog.extensions.hosting\\8.0.0\\serilog.extensions.hosting.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\serilog.extensions.logging\\8.0.0\\serilog.extensions.logging.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\serilog.formatting.compact\\2.0.0\\serilog.formatting.compact.2.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\serilog.settings.configuration\\8.0.0\\serilog.settings.configuration.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\serilog.sinks.console\\5.0.1\\serilog.sinks.console.5.0.1.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\serilog.sinks.debug\\2.0.0\\serilog.sinks.debug.2.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\serilog.sinks.file\\5.0.0\\serilog.sinks.file.5.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\stylecop.analyzers\\1.2.0-beta.556\\stylecop.analyzers.1.2.0-beta.556.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\stylecop.analyzers.unstable\\1.2.0.556\\stylecop.analyzers.unstable.1.2.0.556.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\swashbuckle.aspnetcore\\6.5.0\\swashbuckle.aspnetcore.6.5.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\swashbuckle.aspnetcore.swagger\\6.5.0\\swashbuckle.aspnetcore.swagger.6.5.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\swashbuckle.aspnetcore.swaggergen\\6.5.0\\swashbuckle.aspnetcore.swaggergen.6.5.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\swashbuckle.aspnetcore.swaggerui\\6.5.0\\swashbuckle.aspnetcore.swaggerui.6.5.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\system.diagnostics.diagnosticsource\\8.0.0\\system.diagnostics.diagnosticsource.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\system.io.hashing\\6.0.0\\system.io.hashing.6.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\system.memory.data\\1.0.2\\system.memory.data.1.0.2.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\system.numerics.vectors\\4.5.0\\system.numerics.vectors.4.5.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\system.text.encodings.web\\8.0.0\\system.text.encodings.web.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\system.text.json\\8.0.0\\system.text.json.8.0.0.nupkg.sha512",
"C:\\Users\\yaoji\\.nuget\\packages\\system.threading.tasks.extensions\\4.5.4\\system.threading.tasks.extensions.4.5.4.nupkg.sha512"
],
"logs": [
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1301",
"level": "Error",
"message": "Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.\r\n Response status code does not indicate success: 401 (Unauthorized).",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
},
{
"code": "NU1900",
"level": "Error",
"message": "Warning As Error: Error occurred while getting package vulnerability data: Unable to load the service index for source https://pkgs.dev.azure.com/billodev/2c2b8bbf-61f2-43f4-b4bb-2017cef20a2c/_packaging/BilloFeed/nuget/v3/index.json.",
"projectPath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"filePath": "C:\\Users\\yaoji\\git\\ColaCoder\\accounting-system\\backend\\src\\InvoiceMaster.API\\InvoiceMaster.API.csproj",
"targetGraphs": []
}
]
}

View File

@@ -0,0 +1,65 @@
using InvoiceMaster.Application.Commands.Auth;
using InvoiceMaster.Application.Services;
using MediatR;
namespace InvoiceMaster.Application.Commands.Auth.Handlers;
public class RegisterCommandHandler : IRequestHandler<RegisterCommand, AuthResultDto>
{
private readonly IAuthService _authService;
public RegisterCommandHandler(IAuthService authService)
{
_authService = authService;
}
public async Task<AuthResultDto> Handle(RegisterCommand request, CancellationToken cancellationToken)
{
return await _authService.RegisterAsync(request, cancellationToken);
}
}
public class LoginCommandHandler : IRequestHandler<LoginCommand, AuthResultDto>
{
private readonly IAuthService _authService;
public LoginCommandHandler(IAuthService authService)
{
_authService = authService;
}
public async Task<AuthResultDto> Handle(LoginCommand request, CancellationToken cancellationToken)
{
return await _authService.LoginAsync(request, cancellationToken);
}
}
public class RefreshTokenCommandHandler : IRequestHandler<RefreshTokenCommand, TokenResultDto?>
{
private readonly IAuthService _authService;
public RefreshTokenCommandHandler(IAuthService authService)
{
_authService = authService;
}
public async Task<TokenResultDto?> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
return await _authService.RefreshTokenAsync(request.RefreshToken, cancellationToken);
}
}
public class LogoutCommandHandler : IRequestHandler<LogoutCommand, bool>
{
private readonly IAuthService _authService;
public LogoutCommandHandler(IAuthService authService)
{
_authService = authService;
}
public async Task<bool> Handle(LogoutCommand request, CancellationToken cancellationToken)
{
return await _authService.LogoutAsync(request.UserId, cancellationToken);
}
}

Some files were not shown because too many files have changed in this diff Show More