Commit inicial - upload de todos os arquivos da pasta
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: "OPSX: Apply"
|
||||
description: Implement tasks from an OpenSpec change (Experimental)
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! You can archive this change with `/opsx:archive`.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: "OPSX: Archive"
|
||||
description: Archive a completed change in the experimental workflow
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, archive, experimental]
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p "<planningHome.changesDir>/archive"
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move `changeRoot` to the archive directory
|
||||
|
||||
```bash
|
||||
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Spec sync status (synced / sync skipped / no delta specs)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success (No Delta Specs)**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** No delta specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success With Warnings**
|
||||
|
||||
```
|
||||
## Archive Complete (with warnings)
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** Sync skipped (user chose to skip)
|
||||
|
||||
**Warnings:**
|
||||
- Archived with 2 incomplete artifacts
|
||||
- Archived with 3 incomplete tasks
|
||||
- Delta spec sync was skipped (user chose to skip)
|
||||
|
||||
Review the archive if this was not intentional.
|
||||
```
|
||||
|
||||
**Output On Error (Archive Exists)**
|
||||
|
||||
```
|
||||
## Archive Failed
|
||||
|
||||
**Change:** <change-name>
|
||||
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
|
||||
Target archive directory already exists.
|
||||
|
||||
**Options:**
|
||||
1. Rename the existing archive
|
||||
2. Delete the existing archive if it's a duplicate
|
||||
3. Wait until a different date to archive
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
name: "OPSX: Continue"
|
||||
description: Continue working on a change - create the next artifact (Experimental)
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
||||
Continue working on a change by creating the next artifact.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:continue` (e.g., `/opsx:continue add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on.
|
||||
|
||||
Present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from `lastModified` field)
|
||||
|
||||
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check current status**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand current state. The response includes:
|
||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
3. **Act based on status**:
|
||||
|
||||
---
|
||||
|
||||
**If all artifacts are complete (`isComplete: true`)**:
|
||||
- Congratulate the user
|
||||
- Show final status including the schema used
|
||||
- Suggest: "All artifacts created! You can now implement this change with `/opsx:apply` or archive it with `/opsx:archive`."
|
||||
- STOP
|
||||
|
||||
---
|
||||
|
||||
**If artifacts are ready to create** (status shows artifacts with `status: "ready"`):
|
||||
- Pick the FIRST artifact with `status: "ready"` from the status output
|
||||
- Get its instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- Parse the JSON. The key fields are:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance
|
||||
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- **Create the artifact file**:
|
||||
- Read any completed dependency files for context
|
||||
- Use `template` as the structure - fill in its sections
|
||||
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
|
||||
- Write to the `resolvedOutputPath` specified in instructions. If it is a glob pattern, choose the concrete file path using the schema instruction and the change's context
|
||||
- Show what was created and what's now unlocked
|
||||
- STOP after creating ONE artifact
|
||||
|
||||
---
|
||||
|
||||
**If no artifacts are ready (all blocked)**:
|
||||
- This shouldn't happen with a valid schema
|
||||
- Show status and suggest checking for issues
|
||||
|
||||
4. **After creating an artifact, show progress**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After each invocation, show:
|
||||
- Which artifact was created
|
||||
- Schema workflow being used
|
||||
- Current progress (N/M complete)
|
||||
- What artifacts are now unlocked
|
||||
- Prompt: "Run `/opsx:continue` to create the next artifact"
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
The artifact types and their purpose depend on the schema. Use the `instruction` field from the instructions output to understand what to create.
|
||||
|
||||
Common artifact patterns:
|
||||
|
||||
**spec-driven schema** (proposal → specs → design → tasks):
|
||||
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
|
||||
- The Capabilities section is critical - each capability listed will need a spec file.
|
||||
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
|
||||
- **design.md**: Document technical decisions, architecture, and implementation approach.
|
||||
- **tasks.md**: Break down implementation into checkboxed tasks.
|
||||
|
||||
For other schemas, follow the `instruction` field from the CLI output.
|
||||
|
||||
**Guardrails**
|
||||
- Create ONE artifact per invocation
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- Never skip artifacts or create out of order
|
||||
- If context is unclear, ask the user before creating
|
||||
- Verify the artifact file exists after writing before marking progress
|
||||
- Use the schema's artifact sequence, don't assume specific artifact names
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
name: "OPSX: Explore"
|
||||
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, explore, experimental, thinking]
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
|
||||
- A vague idea: "real-time collaboration"
|
||||
- A specific problem: "the auth system is getting unwieldy"
|
||||
- A change name: "add-dark-mode" (to explore in context of that change)
|
||||
- A comparison: "postgres vs sqlite for this"
|
||||
- Nothing (just enter explore mode)
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
If the user mentioned a specific change name, read its artifacts for context.
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Resolve and read existing artifacts for context**
|
||||
- Run `openspec status --change "<name>" --json`.
|
||||
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
|
||||
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: "OPSX: New"
|
||||
description: Start a new change using the experimental artifact workflow (OPSX)
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
||||
Start a new change using the experimental artifact-driven approach.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The argument after `/opsx:new` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Determine the workflow schema**
|
||||
|
||||
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
|
||||
|
||||
**Use a different schema only if the user mentions:**
|
||||
- A specific schema name → use `--schema <name>`
|
||||
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
|
||||
|
||||
**Otherwise**: Omit `--schema` to use the default.
|
||||
|
||||
3. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
Add `--schema <name>` only if the user requested a specific workflow.
|
||||
This creates a scaffolded change in the planning home resolved by the CLI.
|
||||
|
||||
4. **Show the artifact status**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Use the returned `planningHome`, `changeRoot`, `artifactPaths`, and `nextSteps` instead of assuming repo-local paths.
|
||||
|
||||
5. **Get instructions for the first artifact**
|
||||
The first artifact depends on the schema. Check the status output to find the first artifact with status "ready".
|
||||
```bash
|
||||
openspec instructions <first-artifact-id> --change "<name>"
|
||||
```
|
||||
This outputs the template and context for creating the first artifact.
|
||||
|
||||
6. **STOP and wait for user direction**
|
||||
|
||||
**Output**
|
||||
|
||||
After completing the steps, summarize:
|
||||
- Change name and location
|
||||
- Schema/workflow being used and its artifact sequence
|
||||
- Current status (0/N artifacts complete)
|
||||
- The template for the first artifact
|
||||
- Prompt: "Ready to create the first artifact? Run `/opsx:continue` or just describe what this change is about and I'll draft it."
|
||||
|
||||
**Guardrails**
|
||||
- Do NOT create any artifacts yet - just show the instructions
|
||||
- Do NOT advance beyond showing the first artifact template
|
||||
- If the name is invalid (not kebab-case), ask for a valid name
|
||||
- If a change with that name already exists, suggest using `/opsx:continue` instead
|
||||
- Pass --schema if using a non-default workflow
|
||||
@@ -0,0 +1,551 @@
|
||||
---
|
||||
name: "OPSX: Onboard"
|
||||
description: Guided onboarding - walk through a complete OpenSpec workflow cycle with narration
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, onboarding, tutorial, learning]
|
||||
---
|
||||
|
||||
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
---
|
||||
|
||||
## Preflight
|
||||
|
||||
Before starting, check if the OpenSpec CLI is installed:
|
||||
|
||||
```bash
|
||||
# Unix/macOS
|
||||
openspec --version 2>&1 || echo "CLI_NOT_INSTALLED"
|
||||
# Windows (PowerShell)
|
||||
# if (Get-Command openspec -ErrorAction SilentlyContinue) { openspec --version } else { echo "CLI_NOT_INSTALLED" }
|
||||
```
|
||||
|
||||
**If CLI not installed:**
|
||||
> OpenSpec CLI is not installed. Install it first, then come back to `/opsx:onboard`.
|
||||
|
||||
Stop here if not installed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Welcome
|
||||
|
||||
Display:
|
||||
|
||||
```
|
||||
## Welcome to OpenSpec!
|
||||
|
||||
I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it.
|
||||
|
||||
**What we'll do:**
|
||||
1. Pick a small, real task in your codebase
|
||||
2. Explore the problem briefly
|
||||
3. Create a change (the container for our work)
|
||||
4. Build the artifacts: proposal → specs → design → tasks
|
||||
5. Implement the tasks
|
||||
6. Archive the completed change
|
||||
|
||||
**Time:** ~15-20 minutes
|
||||
|
||||
Let's start by finding something to work on.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Task Selection
|
||||
|
||||
### Codebase Analysis
|
||||
|
||||
Scan the codebase for small improvement opportunities. Look for:
|
||||
|
||||
1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files
|
||||
2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch
|
||||
3. **Functions without tests** - Cross-reference `src/` with test directories
|
||||
4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`)
|
||||
5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code
|
||||
6. **Missing validation** - User input handlers without validation
|
||||
|
||||
Also check recent git activity:
|
||||
```bash
|
||||
# Unix/macOS
|
||||
git log --oneline -10 2>/dev/null || echo "No git history"
|
||||
# Windows (PowerShell)
|
||||
# git log --oneline -10 2>$null; if ($LASTEXITCODE -ne 0) { echo "No git history" }
|
||||
```
|
||||
|
||||
### Present Suggestions
|
||||
|
||||
From your analysis, present 3-4 specific suggestions:
|
||||
|
||||
```
|
||||
## Task Suggestions
|
||||
|
||||
Based on scanning your codebase, here are some good starter tasks:
|
||||
|
||||
**1. [Most promising task]**
|
||||
Location: `src/path/to/file.ts:42`
|
||||
Scope: ~1-2 files, ~20-30 lines
|
||||
Why it's good: [brief reason]
|
||||
|
||||
**2. [Second task]**
|
||||
Location: `src/another/file.ts`
|
||||
Scope: ~1 file, ~15 lines
|
||||
Why it's good: [brief reason]
|
||||
|
||||
**3. [Third task]**
|
||||
Location: [location]
|
||||
Scope: [estimate]
|
||||
Why it's good: [brief reason]
|
||||
|
||||
**4. Something else?**
|
||||
Tell me what you'd like to work on.
|
||||
|
||||
Which task interests you? (Pick a number or describe your own)
|
||||
```
|
||||
|
||||
**If nothing found:** Fall back to asking what the user wants to build:
|
||||
> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix?
|
||||
|
||||
### Scope Guardrail
|
||||
|
||||
If the user picks or describes something too large (major feature, multi-day work):
|
||||
|
||||
```
|
||||
That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through.
|
||||
|
||||
For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details.
|
||||
|
||||
**Options:**
|
||||
1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]?
|
||||
2. **Pick something else** - One of the other suggestions, or a different small task?
|
||||
3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer.
|
||||
|
||||
What would you prefer?
|
||||
```
|
||||
|
||||
Let the user override if they insist—this is a soft guardrail.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Explore Demo
|
||||
|
||||
Once a task is selected, briefly demonstrate explore mode:
|
||||
|
||||
```
|
||||
Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction.
|
||||
```
|
||||
|
||||
Spend 1-2 minutes investigating the relevant code:
|
||||
- Read the file(s) involved
|
||||
- Draw a quick ASCII diagram if it helps
|
||||
- Note any considerations
|
||||
|
||||
```
|
||||
## Quick Exploration
|
||||
|
||||
[Your brief analysis—what you found, any considerations]
|
||||
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [Optional: ASCII diagram if helpful] │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
||||
|
||||
Now let's create a change to hold our work.
|
||||
```
|
||||
|
||||
**PAUSE** - Wait for user acknowledgment before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Create the Change
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Creating a Change
|
||||
|
||||
A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives at the `changeRoot` reported by `openspec status --change "<name>" --json` and holds your artifacts—proposal, specs, design, tasks.
|
||||
|
||||
Let me create one for our task.
|
||||
```
|
||||
|
||||
**DO:** Create the change with a derived kebab-case name:
|
||||
```bash
|
||||
openspec new change "<derived-name>"
|
||||
```
|
||||
|
||||
**SHOW:**
|
||||
```
|
||||
Created: <changeRoot from status JSON>
|
||||
|
||||
The folder structure:
|
||||
```
|
||||
<changeRoot>/
|
||||
├── proposal.md ← Why we're doing this (empty, we'll fill it)
|
||||
├── design.md ← How we'll build it (empty)
|
||||
├── specs/ ← Detailed requirements (empty)
|
||||
└── tasks.md ← Implementation checklist (empty)
|
||||
```
|
||||
|
||||
Now let's fill in the first artifact—the proposal.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Proposal
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## The Proposal
|
||||
|
||||
The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work.
|
||||
|
||||
I'll draft one based on our task.
|
||||
```
|
||||
|
||||
**DO:** Draft the proposal content (don't save yet):
|
||||
|
||||
```
|
||||
Here's a draft proposal:
|
||||
|
||||
---
|
||||
|
||||
## Why
|
||||
|
||||
[1-2 sentences explaining the problem/opportunity]
|
||||
|
||||
## What Changes
|
||||
|
||||
[Bullet points of what will be different]
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `<capability-name>`: [brief description]
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- If modifying existing behavior -->
|
||||
|
||||
## Impact
|
||||
|
||||
- `src/path/to/file.ts`: [what changes]
|
||||
- [other files if applicable]
|
||||
|
||||
---
|
||||
|
||||
Does this capture the intent? I can adjust before we save it.
|
||||
```
|
||||
|
||||
**PAUSE** - Wait for user approval/feedback.
|
||||
|
||||
After approval, save the proposal:
|
||||
```bash
|
||||
openspec instructions proposal --change "<name>" --json
|
||||
```
|
||||
Then write the content to the `resolvedOutputPath` from `openspec instructions proposal --change "<name>" --json`.
|
||||
|
||||
```
|
||||
Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves.
|
||||
|
||||
Next up: specs.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Specs
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Specs
|
||||
|
||||
Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear.
|
||||
|
||||
For a small task like this, we might only need one spec file.
|
||||
```
|
||||
|
||||
**DO:** Resolve where the spec file should be created:
|
||||
```bash
|
||||
openspec instructions specs --change "<name>" --json
|
||||
# Use resolvedOutputPath from the JSON. If it is a glob, choose the concrete file path using the schema instruction and the change's context.
|
||||
```
|
||||
|
||||
Draft the spec content:
|
||||
|
||||
```
|
||||
Here's the spec:
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: <Name>
|
||||
|
||||
<Description of what the system should do>
|
||||
|
||||
#### Scenario: <Scenario name>
|
||||
|
||||
- **WHEN** <trigger condition>
|
||||
- **THEN** <expected outcome>
|
||||
- **AND** <additional outcome if needed>
|
||||
|
||||
---
|
||||
|
||||
This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases.
|
||||
```
|
||||
|
||||
Save to the concrete file path chosen from `resolvedOutputPath`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Design
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Design
|
||||
|
||||
The design captures **how** we'll build it—technical decisions, tradeoffs, approach.
|
||||
|
||||
For small changes, this might be brief. That's fine—not every change needs deep design discussion.
|
||||
```
|
||||
|
||||
**DO:** Draft design.md:
|
||||
|
||||
```
|
||||
Here's the design:
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
[Brief context about the current state]
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- [What we're trying to achieve]
|
||||
|
||||
**Non-Goals:**
|
||||
- [What's explicitly out of scope]
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: [Key decision]
|
||||
|
||||
[Explanation of approach and rationale]
|
||||
|
||||
---
|
||||
|
||||
For a small task, this captures the key decisions without over-engineering.
|
||||
```
|
||||
|
||||
Save to the `resolvedOutputPath` from `openspec instructions design --change "<name>" --json`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Tasks
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Tasks
|
||||
|
||||
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
|
||||
|
||||
These should be small, clear, and in logical order.
|
||||
```
|
||||
|
||||
**DO:** Generate tasks based on specs and design:
|
||||
|
||||
```
|
||||
Here are the implementation tasks:
|
||||
|
||||
---
|
||||
|
||||
## 1. [Category or file]
|
||||
|
||||
- [ ] 1.1 [Specific task]
|
||||
- [ ] 1.2 [Specific task]
|
||||
|
||||
## 2. Verify
|
||||
|
||||
- [ ] 2.1 [Verification step]
|
||||
|
||||
---
|
||||
|
||||
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
|
||||
```
|
||||
|
||||
**PAUSE** - Wait for user to confirm they're ready to implement.
|
||||
|
||||
Save to the `resolvedOutputPath` from `openspec instructions tasks --change "<name>" --json`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Apply (Implementation)
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Implementation
|
||||
|
||||
Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach.
|
||||
```
|
||||
|
||||
**DO:** For each task:
|
||||
|
||||
1. Announce: "Working on task N: [description]"
|
||||
2. Implement the change in the codebase
|
||||
3. Reference specs/design naturally: "The spec says X, so I'm doing Y"
|
||||
4. Mark complete in tasks.md: `- [ ]` → `- [x]`
|
||||
5. Brief status: "✓ Task N complete"
|
||||
|
||||
Keep narration light—don't over-explain every line of code.
|
||||
|
||||
After all tasks:
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
All tasks done:
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
- [x] ...
|
||||
|
||||
The change is implemented! One more step—let's archive it.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Archive
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Archiving
|
||||
|
||||
When a change is complete, we archive it. The archive path is derived from `planningHome.changesDir` and the date.
|
||||
|
||||
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
|
||||
```
|
||||
|
||||
**DO:**
|
||||
```bash
|
||||
openspec archive "<name>"
|
||||
```
|
||||
|
||||
**SHOW:**
|
||||
```
|
||||
Archived to: `<planningHome.changesDir>/archive/YYYY-MM-DD-<name>/`
|
||||
|
||||
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: Recap & Next Steps
|
||||
|
||||
```
|
||||
## Congratulations!
|
||||
|
||||
You just completed a full OpenSpec cycle:
|
||||
|
||||
1. **Explore** - Thought through the problem
|
||||
2. **New** - Created a change container
|
||||
3. **Proposal** - Captured WHY
|
||||
4. **Specs** - Defined WHAT in detail
|
||||
5. **Design** - Decided HOW
|
||||
6. **Tasks** - Broke it into steps
|
||||
7. **Apply** - Implemented the work
|
||||
8. **Archive** - Preserved the record
|
||||
|
||||
This same rhythm works for any size change—a small fix or a major feature.
|
||||
|
||||
---
|
||||
|
||||
## Command Reference
|
||||
|
||||
**Core workflow:**
|
||||
|
||||
| Command | What it does |
|
||||
|-------------------|--------------------------------------------|
|
||||
| `/opsx:propose` | Create a change and generate all artifacts |
|
||||
| `/opsx:explore` | Think through problems before/during work |
|
||||
| `/opsx:apply` | Implement tasks from a change |
|
||||
| `/opsx:archive` | Archive a completed change |
|
||||
|
||||
**Additional commands:**
|
||||
|
||||
| Command | What it does |
|
||||
|--------------------|----------------------------------------------------------|
|
||||
| `/opsx:new` | Start a new change, step through artifacts one at a time |
|
||||
| `/opsx:continue` | Continue working on an existing change |
|
||||
| `/opsx:ff` | Fast-forward: create all artifacts at once |
|
||||
| `/opsx:verify` | Verify implementation matches artifacts |
|
||||
|
||||
---
|
||||
|
||||
## What's Next?
|
||||
|
||||
Try `/opsx:propose` on something you actually want to build. You've got the rhythm now!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Graceful Exit Handling
|
||||
|
||||
### User wants to stop mid-way
|
||||
|
||||
If the user says they need to stop, want to pause, or seem disengaged:
|
||||
|
||||
```
|
||||
No problem! Your change is saved at the `changeRoot` reported by `openspec status --change "<name>" --json`.
|
||||
|
||||
To pick up where we left off later:
|
||||
- `/opsx:continue <name>` - Resume artifact creation
|
||||
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
|
||||
|
||||
The work won't be lost. Come back whenever you're ready.
|
||||
```
|
||||
|
||||
Exit gracefully without pressure.
|
||||
|
||||
### User just wants command reference
|
||||
|
||||
If the user says they just want to see the commands or skip the tutorial:
|
||||
|
||||
```
|
||||
## OpenSpec Quick Reference
|
||||
|
||||
**Core workflow:**
|
||||
|
||||
| Command | What it does |
|
||||
|--------------------------|--------------------------------------------|
|
||||
| `/opsx:propose <name>` | Create a change and generate all artifacts |
|
||||
| `/opsx:explore` | Think through problems (no code changes) |
|
||||
| `/opsx:apply <name>` | Implement tasks |
|
||||
| `/opsx:archive <name>` | Archive when done |
|
||||
|
||||
**Additional commands:**
|
||||
|
||||
| Command | What it does |
|
||||
|---------------------------|-------------------------------------|
|
||||
| `/opsx:new <name>` | Start a new change, step by step |
|
||||
| `/opsx:continue <name>` | Continue an existing change |
|
||||
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
|
||||
| `/opsx:verify <name>` | Verify implementation |
|
||||
|
||||
Try `/opsx:propose` to start your first change.
|
||||
```
|
||||
|
||||
Exit gracefully.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive)
|
||||
- **Keep narration light** during implementation—teach without lecturing
|
||||
- **Don't skip phases** even if the change is small—the goal is teaching the workflow
|
||||
- **Pause for acknowledgment** at marked points, but don't over-pause
|
||||
- **Handle exits gracefully**—never pressure the user to continue
|
||||
- **Use real codebase tasks**—don't simulate or use fake examples
|
||||
- **Adjust scope gently**—guide toward smaller tasks but respect user choice
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: "OPSX: Propose"
|
||||
description: Propose a new change - create it and generate all artifacts in one step
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx:apply
|
||||
|
||||
---
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` to start implementing."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
name: "OPSX: Sync"
|
||||
description: Sync delta specs from a change to main specs
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, specs, experimental]
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show changes that have delta specs (under `specs/` directory).
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Resolve change context**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
|
||||
3. **Find delta specs**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
|
||||
|
||||
Each delta spec file contains sections like:
|
||||
- `## ADDED Requirements` - New requirements to add
|
||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
||||
- `## REMOVED Requirements` - Requirements to remove
|
||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
||||
|
||||
If no delta specs found, inform user and stop.
|
||||
|
||||
4. **For each delta spec, apply changes to main specs**
|
||||
|
||||
For each repo-local capability delta spec path returned by the CLI:
|
||||
|
||||
a. **Read the delta spec** to understand the intended changes
|
||||
|
||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
||||
|
||||
c. **Apply changes intelligently**:
|
||||
|
||||
**ADDED Requirements:**
|
||||
- If requirement doesn't exist in main spec → add it
|
||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
||||
|
||||
**MODIFIED Requirements:**
|
||||
- Find the requirement in main spec
|
||||
- Apply the changes - this can be:
|
||||
- Adding new scenarios (don't need to copy existing ones)
|
||||
- Modifying existing scenarios
|
||||
- Changing the requirement description
|
||||
- Preserve scenarios/content not mentioned in the delta
|
||||
|
||||
**REMOVED Requirements:**
|
||||
- Remove the entire requirement block from main spec
|
||||
|
||||
**RENAMED Requirements:**
|
||||
- Find the FROM requirement, rename to TO
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create `openspec/specs/<capability>/spec.md`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Requirements section with the ADDED requirements
|
||||
|
||||
5. **Show summary**
|
||||
|
||||
After applying all changes, summarize:
|
||||
- Which capabilities were updated
|
||||
- What changes were made (requirements added/modified/removed/renamed)
|
||||
|
||||
**Delta Spec Format Reference**
|
||||
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
The system SHALL do something new.
|
||||
|
||||
#### Scenario: Basic case
|
||||
- **WHEN** user does X
|
||||
- **THEN** system does Y
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Existing Feature
|
||||
#### Scenario: New scenario to add
|
||||
- **WHEN** user does A
|
||||
- **THEN** system does B
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Deprecated Feature
|
||||
|
||||
## RENAMED Requirements
|
||||
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
|
||||
**Key Principle: Intelligent Merging**
|
||||
|
||||
Unlike programmatic merging, you can apply **partial updates**:
|
||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
||||
- The delta represents *intent*, not a wholesale replacement
|
||||
- Use your judgment to merge changes sensibly
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Specs Synced: <change-name>
|
||||
|
||||
Updated main specs:
|
||||
|
||||
**<capability-1>**:
|
||||
- Added requirement: "New Feature"
|
||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
||||
|
||||
**<capability-2>**:
|
||||
- Created new spec file
|
||||
- Added requirement: "Another Feature"
|
||||
|
||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Read both delta and main specs before making changes
|
||||
- Preserve existing content not mentioned in delta
|
||||
- If something is unclear, ask for clarification
|
||||
- Show what you're changing as you go
|
||||
- The operation should be idempotent - running twice should give same result
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: "OPSX: Verify"
|
||||
description: Verify implementation matches change artifacts before archiving
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, verify, experimental]
|
||||
---
|
||||
|
||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:verify` (e.g., `/opsx:verify add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show changes that have implementation tasks (tasks artifact exists).
|
||||
Include the schema used for each change if available.
|
||||
Mark changes with incomplete tasks as "(In Progress)".
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- Which artifacts exist for this change
|
||||
|
||||
3. **Get planning context and load artifacts**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns the change directory and `contextFiles` (artifact ID -> array of concrete file paths). Read all available artifacts from `contextFiles`.
|
||||
|
||||
4. **Initialize verification report structure**
|
||||
|
||||
Create a report structure with three dimensions:
|
||||
- **Completeness**: Track tasks and spec coverage
|
||||
- **Correctness**: Track requirement implementation and scenario coverage
|
||||
- **Coherence**: Track design adherence and pattern consistency
|
||||
|
||||
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
|
||||
|
||||
5. **Verify Completeness**
|
||||
|
||||
**Task Completion**:
|
||||
- If `contextFiles.tasks` exists, read every file path in it
|
||||
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
|
||||
- Count complete vs total tasks
|
||||
- If incomplete tasks exist:
|
||||
- Add CRITICAL issue for each incomplete task
|
||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
||||
|
||||
**Spec Coverage**:
|
||||
- If delta specs exist in `contextFiles.specs`:
|
||||
- Extract all requirements (marked with "### Requirement:")
|
||||
- For each requirement:
|
||||
- Search codebase for keywords related to the requirement
|
||||
- Assess if implementation likely exists
|
||||
- If requirements appear unimplemented:
|
||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
||||
- Recommendation: "Implement requirement X: <description>"
|
||||
|
||||
6. **Verify Correctness**
|
||||
|
||||
**Requirement Implementation Mapping**:
|
||||
- For each requirement from delta specs:
|
||||
- Search codebase for implementation evidence
|
||||
- If found, note file paths and line ranges
|
||||
- Assess if implementation matches requirement intent
|
||||
- If divergence detected:
|
||||
- Add WARNING: "Implementation may diverge from spec: <details>"
|
||||
- Recommendation: "Review <file>:<lines> against requirement X"
|
||||
|
||||
**Scenario Coverage**:
|
||||
- For each scenario in delta specs (marked with "#### Scenario:"):
|
||||
- Check if conditions are handled in code
|
||||
- Check if tests exist covering the scenario
|
||||
- If scenario appears uncovered:
|
||||
- Add WARNING: "Scenario not covered: <scenario name>"
|
||||
- Recommendation: "Add test or implementation for scenario: <description>"
|
||||
|
||||
7. **Verify Coherence**
|
||||
|
||||
**Design Adherence**:
|
||||
- If `contextFiles.design` exists:
|
||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
||||
- Verify implementation follows those decisions
|
||||
- If contradiction detected:
|
||||
- Add WARNING: "Design decision not followed: <decision>"
|
||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
||||
- If no design.md: Skip design adherence check, note "No design.md to verify against"
|
||||
|
||||
**Code Pattern Consistency**:
|
||||
- Review new code for consistency with project patterns
|
||||
- Check file naming, directory structure, coding style
|
||||
- If significant deviations found:
|
||||
- Add SUGGESTION: "Code pattern deviation: <details>"
|
||||
- Recommendation: "Consider following project pattern: <example>"
|
||||
|
||||
8. **Generate Verification Report**
|
||||
|
||||
**Summary Scorecard**:
|
||||
```
|
||||
## Verification Report: <change-name>
|
||||
|
||||
### Summary
|
||||
| Dimension | Status |
|
||||
|--------------|------------------|
|
||||
| Completeness | X/Y tasks, N reqs|
|
||||
| Correctness | M/N reqs covered |
|
||||
| Coherence | Followed/Issues |
|
||||
```
|
||||
|
||||
**Issues by Priority**:
|
||||
|
||||
1. **CRITICAL** (Must fix before archive):
|
||||
- Incomplete tasks
|
||||
- Missing requirement implementations
|
||||
- Each with specific, actionable recommendation
|
||||
|
||||
2. **WARNING** (Should fix):
|
||||
- Spec/design divergences
|
||||
- Missing scenario coverage
|
||||
- Each with specific recommendation
|
||||
|
||||
3. **SUGGESTION** (Nice to fix):
|
||||
- Pattern inconsistencies
|
||||
- Minor improvements
|
||||
- Each with specific recommendation
|
||||
|
||||
**Final Assessment**:
|
||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
|
||||
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If all clear: "All checks passed. Ready for archive."
|
||||
|
||||
**Verification Heuristics**
|
||||
|
||||
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
|
||||
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
|
||||
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
|
||||
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
|
||||
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
|
||||
|
||||
**Graceful Degradation**
|
||||
|
||||
- If only tasks.md exists: verify task completion only, skip spec/design checks
|
||||
- If tasks + specs exist: verify completeness and correctness, skip design
|
||||
- If full artifacts: verify all three dimensions
|
||||
- Always note which checks were skipped and why
|
||||
|
||||
**Output Format**
|
||||
|
||||
Use clear markdown with:
|
||||
- Table for summary scorecard
|
||||
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
|
||||
- Code references in format: `file.ts:123`
|
||||
- Specific, actionable recommendations
|
||||
- No vague suggestions like "consider reviewing"
|
||||
@@ -0,0 +1 @@
|
||||
{"sessionId":"df9a1432-bea9-41c0-84f3-c2d1a69c1b77","pid":36484,"acquiredAt":1784943220033}
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
name: openspec-apply-change
|
||||
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! Ready to archive this change.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
name: openspec-archive-change
|
||||
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p "<planningHome.changesDir>/archive"
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move `changeRoot` to the archive directory
|
||||
|
||||
```bash
|
||||
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Whether specs were synced (if applicable)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use openspec-sync-specs approach (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
name: openspec-continue-change
|
||||
description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Continue working on a change by creating the next artifact.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on.
|
||||
|
||||
Present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from `lastModified` field)
|
||||
|
||||
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check current status**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand current state. The response includes:
|
||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
3. **Act based on status**:
|
||||
|
||||
---
|
||||
|
||||
**If all artifacts are complete (`isComplete: true`)**:
|
||||
- Congratulate the user
|
||||
- Show final status including the schema used
|
||||
- Suggest: "All artifacts created! You can now implement this change or archive it."
|
||||
- STOP
|
||||
|
||||
---
|
||||
|
||||
**If artifacts are ready to create** (status shows artifacts with `status: "ready"`):
|
||||
- Pick the FIRST artifact with `status: "ready"` from the status output
|
||||
- Get its instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- Parse the JSON. The key fields are:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance
|
||||
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- **Create the artifact file**:
|
||||
- Read any completed dependency files for context
|
||||
- Use `template` as the structure - fill in its sections
|
||||
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
|
||||
- Write to the `resolvedOutputPath` specified in instructions. If it is a glob pattern, choose the concrete file path using the schema instruction and the change's context
|
||||
- Show what was created and what's now unlocked
|
||||
- STOP after creating ONE artifact
|
||||
|
||||
---
|
||||
|
||||
**If no artifacts are ready (all blocked)**:
|
||||
- This shouldn't happen with a valid schema
|
||||
- Show status and suggest checking for issues
|
||||
|
||||
4. **After creating an artifact, show progress**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After each invocation, show:
|
||||
- Which artifact was created
|
||||
- Schema workflow being used
|
||||
- Current progress (N/M complete)
|
||||
- What artifacts are now unlocked
|
||||
- Prompt: "Want to continue? Just ask me to continue or tell me what to do next."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
The artifact types and their purpose depend on the schema. Use the `instruction` field from the instructions output to understand what to create.
|
||||
|
||||
Common artifact patterns:
|
||||
|
||||
**spec-driven schema** (proposal → specs → design → tasks):
|
||||
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
|
||||
- The Capabilities section is critical - each capability listed will need a spec file.
|
||||
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
|
||||
- **design.md**: Document technical decisions, architecture, and implementation approach.
|
||||
- **tasks.md**: Break down implementation into checkboxed tasks.
|
||||
|
||||
For other schemas, follow the `instruction` field from the CLI output.
|
||||
|
||||
**Guardrails**
|
||||
- Create ONE artifact per invocation
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- Never skip artifacts or create out of order
|
||||
- If context is unclear, ask the user before creating
|
||||
- Verify the artifact file exists after writing before marking progress
|
||||
- Use the schema's artifact sequence, don't assume specific artifact names
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
@@ -0,0 +1,290 @@
|
||||
---
|
||||
name: openspec-explore
|
||||
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Resolve and read existing artifacts for context**
|
||||
- Run `openspec status --change "<name>" --json`.
|
||||
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
|
||||
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Handling Different Entry Points
|
||||
|
||||
**User brings a vague idea:**
|
||||
```
|
||||
User: I'm thinking about adding real-time collaboration
|
||||
|
||||
You: Real-time collab is a big space. Let me think about this...
|
||||
|
||||
COLLABORATION SPECTRUM
|
||||
════════════════════════════════════════════
|
||||
|
||||
Awareness Coordination Sync
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│Presence│ │Cursors │ │ CRDT │
|
||||
│ "3 │ │ Multi │ │Conflict│
|
||||
│online" │ │ select │ │ free │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
│ │ │
|
||||
trivial moderate complex
|
||||
|
||||
Where's your head at?
|
||||
```
|
||||
|
||||
**User brings a specific problem:**
|
||||
```
|
||||
User: The auth system is a mess
|
||||
|
||||
You: [reads codebase]
|
||||
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ CURRENT AUTH FLOW │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Google │ │ GitHub │ │ Email │
|
||||
│ OAuth │ │ OAuth │ │ Magic │
|
||||
└────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │
|
||||
└─────────────┼─────────────┘
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Session │
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Perms │
|
||||
└───────────┘
|
||||
|
||||
I see three tangles. Which one's burning?
|
||||
```
|
||||
|
||||
**User is stuck mid-implementation:**
|
||||
```
|
||||
User: /opsx:explore add-auth-system
|
||||
The OAuth integration is more complex than expected
|
||||
|
||||
You: [reads change artifacts]
|
||||
|
||||
You're on task 4: "Implement OAuth flow"
|
||||
|
||||
Let me trace what's involved...
|
||||
|
||||
[draws diagram, explores options, suggests paths]
|
||||
|
||||
Want to update the design to reflect this?
|
||||
Or add a spike task to investigate?
|
||||
```
|
||||
|
||||
**User wants to compare options:**
|
||||
```
|
||||
User: Should we use Postgres or SQLite?
|
||||
|
||||
You: Generic answer is boring. What's the context?
|
||||
|
||||
User: A CLI tool that tracks local dev environments
|
||||
|
||||
You: That changes everything.
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ CLI TOOL DATA STORAGE │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
Key constraints:
|
||||
• No daemon running
|
||||
• Must work offline
|
||||
• Single user
|
||||
|
||||
SQLite Postgres
|
||||
Deployment embedded ✓ needs server ✗
|
||||
Offline yes ✓ no ✗
|
||||
Single file yes ✓ no ✗
|
||||
|
||||
SQLite. Not even close.
|
||||
|
||||
Unless... is there a sync component?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When it feels like things are crystallizing, you might summarize:
|
||||
|
||||
```
|
||||
## What We Figured Out
|
||||
|
||||
**The problem**: [crystallized understanding]
|
||||
|
||||
**The approach**: [if one emerged]
|
||||
|
||||
**Open questions**: [if any remain]
|
||||
|
||||
**Next steps** (if ready):
|
||||
- Create a change proposal
|
||||
- Keep exploring: just keep talking
|
||||
```
|
||||
|
||||
But this summary is optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: openspec-new-change
|
||||
description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Start a new change using the experimental artifact-driven approach.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no clear input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Determine the workflow schema**
|
||||
|
||||
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
|
||||
|
||||
**Use a different schema only if the user mentions:**
|
||||
- A specific schema name → use `--schema <name>`
|
||||
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
|
||||
|
||||
**Otherwise**: Omit `--schema` to use the default.
|
||||
|
||||
3. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
Add `--schema <name>` only if the user requested a specific workflow.
|
||||
This creates a scaffolded change in the planning home resolved by the CLI.
|
||||
|
||||
4. **Show the artifact status**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Use the returned `planningHome`, `changeRoot`, `artifactPaths`, and `nextSteps` instead of assuming repo-local paths.
|
||||
|
||||
5. **Get instructions for the first artifact**
|
||||
The first artifact depends on the schema (e.g., `proposal` for spec-driven).
|
||||
Check the status output to find the first artifact with status "ready".
|
||||
```bash
|
||||
openspec instructions <first-artifact-id> --change "<name>"
|
||||
```
|
||||
This outputs the template and context for creating the first artifact.
|
||||
|
||||
6. **STOP and wait for user direction**
|
||||
|
||||
**Output**
|
||||
|
||||
After completing the steps, summarize:
|
||||
- Change name and location
|
||||
- Schema/workflow being used and its artifact sequence
|
||||
- Current status (0/N artifacts complete)
|
||||
- The template for the first artifact
|
||||
- Prompt: "Ready to create the first artifact? Just describe what this change is about and I'll draft it, or ask me to continue."
|
||||
|
||||
**Guardrails**
|
||||
- Do NOT create any artifacts yet - just show the instructions
|
||||
- Do NOT advance beyond showing the first artifact template
|
||||
- If the name is invalid (not kebab-case), ask for a valid name
|
||||
- If a change with that name already exists, suggest continuing that change instead
|
||||
- Pass --schema if using a non-default workflow
|
||||
@@ -0,0 +1,555 @@
|
||||
---
|
||||
name: openspec-onboard
|
||||
description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
---
|
||||
|
||||
## Preflight
|
||||
|
||||
Before starting, check if the OpenSpec CLI is installed:
|
||||
|
||||
```bash
|
||||
# Unix/macOS
|
||||
openspec --version 2>&1 || echo "CLI_NOT_INSTALLED"
|
||||
# Windows (PowerShell)
|
||||
# if (Get-Command openspec -ErrorAction SilentlyContinue) { openspec --version } else { echo "CLI_NOT_INSTALLED" }
|
||||
```
|
||||
|
||||
**If CLI not installed:**
|
||||
> OpenSpec CLI is not installed. Install it first, then come back to `/opsx:onboard`.
|
||||
|
||||
Stop here if not installed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Welcome
|
||||
|
||||
Display:
|
||||
|
||||
```
|
||||
## Welcome to OpenSpec!
|
||||
|
||||
I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it.
|
||||
|
||||
**What we'll do:**
|
||||
1. Pick a small, real task in your codebase
|
||||
2. Explore the problem briefly
|
||||
3. Create a change (the container for our work)
|
||||
4. Build the artifacts: proposal → specs → design → tasks
|
||||
5. Implement the tasks
|
||||
6. Archive the completed change
|
||||
|
||||
**Time:** ~15-20 minutes
|
||||
|
||||
Let's start by finding something to work on.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Task Selection
|
||||
|
||||
### Codebase Analysis
|
||||
|
||||
Scan the codebase for small improvement opportunities. Look for:
|
||||
|
||||
1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files
|
||||
2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch
|
||||
3. **Functions without tests** - Cross-reference `src/` with test directories
|
||||
4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`)
|
||||
5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code
|
||||
6. **Missing validation** - User input handlers without validation
|
||||
|
||||
Also check recent git activity:
|
||||
```bash
|
||||
# Unix/macOS
|
||||
git log --oneline -10 2>/dev/null || echo "No git history"
|
||||
# Windows (PowerShell)
|
||||
# git log --oneline -10 2>$null; if ($LASTEXITCODE -ne 0) { echo "No git history" }
|
||||
```
|
||||
|
||||
### Present Suggestions
|
||||
|
||||
From your analysis, present 3-4 specific suggestions:
|
||||
|
||||
```
|
||||
## Task Suggestions
|
||||
|
||||
Based on scanning your codebase, here are some good starter tasks:
|
||||
|
||||
**1. [Most promising task]**
|
||||
Location: `src/path/to/file.ts:42`
|
||||
Scope: ~1-2 files, ~20-30 lines
|
||||
Why it's good: [brief reason]
|
||||
|
||||
**2. [Second task]**
|
||||
Location: `src/another/file.ts`
|
||||
Scope: ~1 file, ~15 lines
|
||||
Why it's good: [brief reason]
|
||||
|
||||
**3. [Third task]**
|
||||
Location: [location]
|
||||
Scope: [estimate]
|
||||
Why it's good: [brief reason]
|
||||
|
||||
**4. Something else?**
|
||||
Tell me what you'd like to work on.
|
||||
|
||||
Which task interests you? (Pick a number or describe your own)
|
||||
```
|
||||
|
||||
**If nothing found:** Fall back to asking what the user wants to build:
|
||||
> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix?
|
||||
|
||||
### Scope Guardrail
|
||||
|
||||
If the user picks or describes something too large (major feature, multi-day work):
|
||||
|
||||
```
|
||||
That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through.
|
||||
|
||||
For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details.
|
||||
|
||||
**Options:**
|
||||
1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]?
|
||||
2. **Pick something else** - One of the other suggestions, or a different small task?
|
||||
3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer.
|
||||
|
||||
What would you prefer?
|
||||
```
|
||||
|
||||
Let the user override if they insist—this is a soft guardrail.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Explore Demo
|
||||
|
||||
Once a task is selected, briefly demonstrate explore mode:
|
||||
|
||||
```
|
||||
Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction.
|
||||
```
|
||||
|
||||
Spend 1-2 minutes investigating the relevant code:
|
||||
- Read the file(s) involved
|
||||
- Draw a quick ASCII diagram if it helps
|
||||
- Note any considerations
|
||||
|
||||
```
|
||||
## Quick Exploration
|
||||
|
||||
[Your brief analysis—what you found, any considerations]
|
||||
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [Optional: ASCII diagram if helpful] │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
||||
|
||||
Now let's create a change to hold our work.
|
||||
```
|
||||
|
||||
**PAUSE** - Wait for user acknowledgment before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Create the Change
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Creating a Change
|
||||
|
||||
A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives at the `changeRoot` reported by `openspec status --change "<name>" --json` and holds your artifacts—proposal, specs, design, tasks.
|
||||
|
||||
Let me create one for our task.
|
||||
```
|
||||
|
||||
**DO:** Create the change with a derived kebab-case name:
|
||||
```bash
|
||||
openspec new change "<derived-name>"
|
||||
```
|
||||
|
||||
**SHOW:**
|
||||
```
|
||||
Created: <changeRoot from status JSON>
|
||||
|
||||
The folder structure:
|
||||
```
|
||||
<changeRoot>/
|
||||
├── proposal.md ← Why we're doing this (empty, we'll fill it)
|
||||
├── design.md ← How we'll build it (empty)
|
||||
├── specs/ ← Detailed requirements (empty)
|
||||
└── tasks.md ← Implementation checklist (empty)
|
||||
```
|
||||
|
||||
Now let's fill in the first artifact—the proposal.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Proposal
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## The Proposal
|
||||
|
||||
The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work.
|
||||
|
||||
I'll draft one based on our task.
|
||||
```
|
||||
|
||||
**DO:** Draft the proposal content (don't save yet):
|
||||
|
||||
```
|
||||
Here's a draft proposal:
|
||||
|
||||
---
|
||||
|
||||
## Why
|
||||
|
||||
[1-2 sentences explaining the problem/opportunity]
|
||||
|
||||
## What Changes
|
||||
|
||||
[Bullet points of what will be different]
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `<capability-name>`: [brief description]
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- If modifying existing behavior -->
|
||||
|
||||
## Impact
|
||||
|
||||
- `src/path/to/file.ts`: [what changes]
|
||||
- [other files if applicable]
|
||||
|
||||
---
|
||||
|
||||
Does this capture the intent? I can adjust before we save it.
|
||||
```
|
||||
|
||||
**PAUSE** - Wait for user approval/feedback.
|
||||
|
||||
After approval, save the proposal:
|
||||
```bash
|
||||
openspec instructions proposal --change "<name>" --json
|
||||
```
|
||||
Then write the content to the `resolvedOutputPath` from `openspec instructions proposal --change "<name>" --json`.
|
||||
|
||||
```
|
||||
Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves.
|
||||
|
||||
Next up: specs.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Specs
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Specs
|
||||
|
||||
Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear.
|
||||
|
||||
For a small task like this, we might only need one spec file.
|
||||
```
|
||||
|
||||
**DO:** Resolve where the spec file should be created:
|
||||
```bash
|
||||
openspec instructions specs --change "<name>" --json
|
||||
# Use resolvedOutputPath from the JSON. If it is a glob, choose the concrete file path using the schema instruction and the change's context.
|
||||
```
|
||||
|
||||
Draft the spec content:
|
||||
|
||||
```
|
||||
Here's the spec:
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: <Name>
|
||||
|
||||
<Description of what the system should do>
|
||||
|
||||
#### Scenario: <Scenario name>
|
||||
|
||||
- **WHEN** <trigger condition>
|
||||
- **THEN** <expected outcome>
|
||||
- **AND** <additional outcome if needed>
|
||||
|
||||
---
|
||||
|
||||
This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases.
|
||||
```
|
||||
|
||||
Save to the concrete file path chosen from `resolvedOutputPath`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Design
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Design
|
||||
|
||||
The design captures **how** we'll build it—technical decisions, tradeoffs, approach.
|
||||
|
||||
For small changes, this might be brief. That's fine—not every change needs deep design discussion.
|
||||
```
|
||||
|
||||
**DO:** Draft design.md:
|
||||
|
||||
```
|
||||
Here's the design:
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
[Brief context about the current state]
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- [What we're trying to achieve]
|
||||
|
||||
**Non-Goals:**
|
||||
- [What's explicitly out of scope]
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: [Key decision]
|
||||
|
||||
[Explanation of approach and rationale]
|
||||
|
||||
---
|
||||
|
||||
For a small task, this captures the key decisions without over-engineering.
|
||||
```
|
||||
|
||||
Save to the `resolvedOutputPath` from `openspec instructions design --change "<name>" --json`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Tasks
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Tasks
|
||||
|
||||
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
|
||||
|
||||
These should be small, clear, and in logical order.
|
||||
```
|
||||
|
||||
**DO:** Generate tasks based on specs and design:
|
||||
|
||||
```
|
||||
Here are the implementation tasks:
|
||||
|
||||
---
|
||||
|
||||
## 1. [Category or file]
|
||||
|
||||
- [ ] 1.1 [Specific task]
|
||||
- [ ] 1.2 [Specific task]
|
||||
|
||||
## 2. Verify
|
||||
|
||||
- [ ] 2.1 [Verification step]
|
||||
|
||||
---
|
||||
|
||||
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
|
||||
```
|
||||
|
||||
**PAUSE** - Wait for user to confirm they're ready to implement.
|
||||
|
||||
Save to the `resolvedOutputPath` from `openspec instructions tasks --change "<name>" --json`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Apply (Implementation)
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Implementation
|
||||
|
||||
Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach.
|
||||
```
|
||||
|
||||
**DO:** For each task:
|
||||
|
||||
1. Announce: "Working on task N: [description]"
|
||||
2. Implement the change in the codebase
|
||||
3. Reference specs/design naturally: "The spec says X, so I'm doing Y"
|
||||
4. Mark complete in tasks.md: `- [ ]` → `- [x]`
|
||||
5. Brief status: "✓ Task N complete"
|
||||
|
||||
Keep narration light—don't over-explain every line of code.
|
||||
|
||||
After all tasks:
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
All tasks done:
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
- [x] ...
|
||||
|
||||
The change is implemented! One more step—let's archive it.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Archive
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Archiving
|
||||
|
||||
When a change is complete, we archive it. The archive path is derived from `planningHome.changesDir` and the date.
|
||||
|
||||
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
|
||||
```
|
||||
|
||||
**DO:**
|
||||
```bash
|
||||
openspec archive "<name>"
|
||||
```
|
||||
|
||||
**SHOW:**
|
||||
```
|
||||
Archived to: `<planningHome.changesDir>/archive/YYYY-MM-DD-<name>/`
|
||||
|
||||
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: Recap & Next Steps
|
||||
|
||||
```
|
||||
## Congratulations!
|
||||
|
||||
You just completed a full OpenSpec cycle:
|
||||
|
||||
1. **Explore** - Thought through the problem
|
||||
2. **New** - Created a change container
|
||||
3. **Proposal** - Captured WHY
|
||||
4. **Specs** - Defined WHAT in detail
|
||||
5. **Design** - Decided HOW
|
||||
6. **Tasks** - Broke it into steps
|
||||
7. **Apply** - Implemented the work
|
||||
8. **Archive** - Preserved the record
|
||||
|
||||
This same rhythm works for any size change—a small fix or a major feature.
|
||||
|
||||
---
|
||||
|
||||
## Command Reference
|
||||
|
||||
**Core workflow:**
|
||||
|
||||
| Command | What it does |
|
||||
|-------------------|--------------------------------------------|
|
||||
| `/opsx:propose` | Create a change and generate all artifacts |
|
||||
| `/opsx:explore` | Think through problems before/during work |
|
||||
| `/opsx:apply` | Implement tasks from a change |
|
||||
| `/opsx:archive` | Archive a completed change |
|
||||
|
||||
**Additional commands:**
|
||||
|
||||
| Command | What it does |
|
||||
|--------------------|----------------------------------------------------------|
|
||||
| `/opsx:new` | Start a new change, step through artifacts one at a time |
|
||||
| `/opsx:continue` | Continue working on an existing change |
|
||||
| `/opsx:ff` | Fast-forward: create all artifacts at once |
|
||||
| `/opsx:verify` | Verify implementation matches artifacts |
|
||||
|
||||
---
|
||||
|
||||
## What's Next?
|
||||
|
||||
Try `/opsx:propose` on something you actually want to build. You've got the rhythm now!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Graceful Exit Handling
|
||||
|
||||
### User wants to stop mid-way
|
||||
|
||||
If the user says they need to stop, want to pause, or seem disengaged:
|
||||
|
||||
```
|
||||
No problem! Your change is saved at the `changeRoot` reported by `openspec status --change "<name>" --json`.
|
||||
|
||||
To pick up where we left off later:
|
||||
- `/opsx:continue <name>` - Resume artifact creation
|
||||
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
|
||||
|
||||
The work won't be lost. Come back whenever you're ready.
|
||||
```
|
||||
|
||||
Exit gracefully without pressure.
|
||||
|
||||
### User just wants command reference
|
||||
|
||||
If the user says they just want to see the commands or skip the tutorial:
|
||||
|
||||
```
|
||||
## OpenSpec Quick Reference
|
||||
|
||||
**Core workflow:**
|
||||
|
||||
| Command | What it does |
|
||||
|--------------------------|--------------------------------------------|
|
||||
| `/opsx:propose <name>` | Create a change and generate all artifacts |
|
||||
| `/opsx:explore` | Think through problems (no code changes) |
|
||||
| `/opsx:apply <name>` | Implement tasks |
|
||||
| `/opsx:archive <name>` | Archive when done |
|
||||
|
||||
**Additional commands:**
|
||||
|
||||
| Command | What it does |
|
||||
|---------------------------|-------------------------------------|
|
||||
| `/opsx:new <name>` | Start a new change, step by step |
|
||||
| `/opsx:continue <name>` | Continue an existing change |
|
||||
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
|
||||
| `/opsx:verify <name>` | Verify implementation |
|
||||
|
||||
Try `/opsx:propose` to start your first change.
|
||||
```
|
||||
|
||||
Exit gracefully.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive)
|
||||
- **Keep narration light** during implementation—teach without lecturing
|
||||
- **Don't skip phases** even if the change is small—the goal is teaching the workflow
|
||||
- **Pause for acknowledgment** at marked points, but don't over-pause
|
||||
- **Handle exits gracefully**—never pressure the user to continue
|
||||
- **Use real codebase tasks**—don't simulate or use fake examples
|
||||
- **Adjust scope gently**—guide toward smaller tasks but respect user choice
|
||||
@@ -0,0 +1,65 @@
|
||||
Tenho uma ideia/problema
|
||||
↓
|
||||
/opsx:explore
|
||||
↓
|
||||
/opsx:propose minha-mudanca
|
||||
↓
|
||||
/opsx:apply minha-mudanca
|
||||
↓
|
||||
/opsx:verify minha-mudanca
|
||||
↓
|
||||
/opsx:archive minha-mudanca
|
||||
↓
|
||||
CONCLUÍDO
|
||||
//////////////////////////////////
|
||||
| Comando | Significado |
|
||||
| --------------------- | ------------------------------ |
|
||||
| `/opsx:explore` | 🔍 Investigar/pensar |
|
||||
| `/opsx:propose nome` | 📝 Planejar uma nova alteração |
|
||||
| `/opsx:apply nome` | 🔨 Implementar |
|
||||
| `/opsx:verify nome` | ✅ Conferir implementação |
|
||||
| `/opsx:archive nome` | 📦 Finalizar |
|
||||
| `/opsx:onboard` | 🎓 Aprender o processo |
|
||||
| `/opsx:new nome` | Criar mudança passo a passo |
|
||||
| `/opsx:continue nome` | Continuar mudança |
|
||||
| `/opsx:ff nome` | ⚡ Gerar artefatos rapidamente |
|
||||
////////////////////////////////
|
||||
analise td projeto
|
||||
Objetivos:
|
||||
1. criar tabela categoria campos: id, categoria, palavra_chave
|
||||
|
||||
Funcionalidades:
|
||||
1. AI deve ler nome do fornecedor e categorizar automaticamente, caso não consiga
|
||||
a AI deve consultar as palavras chaves da tabela categoria se encontrar utilizar o campo categoria para categorizar
|
||||
2. Ajustar Dashboard para mostrar e filtrar os gastos por categoria
|
||||
3. criar mecanismo de segurança para não entrar em loop infinito qdo acionar AI, PARA EVITAR CONSUMO exagerado de tokens
|
||||
4. criar "CRUD" tabela categoria, seguir mesmo layout atual do projeto
|
||||
5. Qdo AI não conseguir categorizar mesmo procurando por palavra_chave na tabela categoria deve gravar no banco: "Não Encontrado" com Id = 01 da tabela de categoria/clear
|
||||
///////////////////////////////
|
||||
1. acrescentar no Formulario "Documents": Select (tabela categoria ASC) campo: categoria na rota: /documents/new
|
||||
2. Em Dashboard: "Ultimos Lançamentos" incluir coluna: "Categoria" logo apos coluna fornecedor
|
||||
3. Rota: /documents incluir coluna: "Categoria" logo apos coluna fornecedor
|
||||
///////////////////////////////
|
||||
Objetivo: atualmente OCR captura data do documento, mudar para competencia tipo: mes/ano Exemplo: Jun/26, Jul/26. Então:
|
||||
1. alterar estrutura da tabela remover campo data
|
||||
2. Criar campos: mes e ano
|
||||
3. Antes de clicar no botão: Enviar revisar Perguntar Competencia: Mes/Ano
|
||||
4. Preencher campo: Competencia deve: Select mes (Jan,Fev,Mar,Abr,... Dez) e Ano (2026,2027,2028,2029,2030)
|
||||
5. Carregar no banco dados
|
||||
6. Aplicar correções no Dashboard e filtros em tds rotas
|
||||
|
||||
//////////////////////
|
||||
Comandos usados:
|
||||
|
||||
# 1. Encontrar o processo que ocupava a porta 8000
|
||||
netstat -ano | findstr :8000
|
||||
wmic process where "ProcessId=<PID>" get ProcessId,ParentProcessId,CommandLine
|
||||
|
||||
# 2. Finalizar o processo antigo
|
||||
taskkill /F /PID <PID>
|
||||
|
||||
# 3. Reiniciar o servidor
|
||||
cd C:\LerNotaFiscal
|
||||
.venv\Scripts\python.exe app.py
|
||||
|
||||
Servidor rodando de novo em http://127.0.0.1:8000.
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: openspec-propose
|
||||
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx:apply
|
||||
|
||||
---
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no clear input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: openspec-sync-specs
|
||||
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show changes that have delta specs (under `specs/` directory).
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Resolve change context**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
|
||||
3. **Find delta specs**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
|
||||
|
||||
Each delta spec file contains sections like:
|
||||
- `## ADDED Requirements` - New requirements to add
|
||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
||||
- `## REMOVED Requirements` - Requirements to remove
|
||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
||||
|
||||
If no delta specs found, inform user and stop.
|
||||
|
||||
4. **For each delta spec, apply changes to main specs**
|
||||
|
||||
For each repo-local capability delta spec path returned by the CLI:
|
||||
|
||||
a. **Read the delta spec** to understand the intended changes
|
||||
|
||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
||||
|
||||
c. **Apply changes intelligently**:
|
||||
|
||||
**ADDED Requirements:**
|
||||
- If requirement doesn't exist in main spec → add it
|
||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
||||
|
||||
**MODIFIED Requirements:**
|
||||
- Find the requirement in main spec
|
||||
- Apply the changes - this can be:
|
||||
- Adding new scenarios (don't need to copy existing ones)
|
||||
- Modifying existing scenarios
|
||||
- Changing the requirement description
|
||||
- Preserve scenarios/content not mentioned in the delta
|
||||
|
||||
**REMOVED Requirements:**
|
||||
- Remove the entire requirement block from main spec
|
||||
|
||||
**RENAMED Requirements:**
|
||||
- Find the FROM requirement, rename to TO
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create `openspec/specs/<capability>/spec.md`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Requirements section with the ADDED requirements
|
||||
|
||||
5. **Show summary**
|
||||
|
||||
After applying all changes, summarize:
|
||||
- Which capabilities were updated
|
||||
- What changes were made (requirements added/modified/removed/renamed)
|
||||
|
||||
**Delta Spec Format Reference**
|
||||
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
The system SHALL do something new.
|
||||
|
||||
#### Scenario: Basic case
|
||||
- **WHEN** user does X
|
||||
- **THEN** system does Y
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Existing Feature
|
||||
#### Scenario: New scenario to add
|
||||
- **WHEN** user does A
|
||||
- **THEN** system does B
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Deprecated Feature
|
||||
|
||||
## RENAMED Requirements
|
||||
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
|
||||
**Key Principle: Intelligent Merging**
|
||||
|
||||
Unlike programmatic merging, you can apply **partial updates**:
|
||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
||||
- The delta represents *intent*, not a wholesale replacement
|
||||
- Use your judgment to merge changes sensibly
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Specs Synced: <change-name>
|
||||
|
||||
Updated main specs:
|
||||
|
||||
**<capability-1>**:
|
||||
- Added requirement: "New Feature"
|
||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
||||
|
||||
**<capability-2>**:
|
||||
- Created new spec file
|
||||
- Added requirement: "Another Feature"
|
||||
|
||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Read both delta and main specs before making changes
|
||||
- Preserve existing content not mentioned in delta
|
||||
- If something is unclear, ask for clarification
|
||||
- Show what you're changing as you go
|
||||
- The operation should be idempotent - running twice should give same result
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
name: openspec-verify-change
|
||||
description: Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show changes that have implementation tasks (tasks artifact exists).
|
||||
Include the schema used for each change if available.
|
||||
Mark changes with incomplete tasks as "(In Progress)".
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- Which artifacts exist for this change
|
||||
|
||||
3. **Get planning context and load artifacts**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns the change directory and `contextFiles` (artifact ID -> array of concrete file paths). Read all available artifacts from `contextFiles`.
|
||||
|
||||
4. **Initialize verification report structure**
|
||||
|
||||
Create a report structure with three dimensions:
|
||||
- **Completeness**: Track tasks and spec coverage
|
||||
- **Correctness**: Track requirement implementation and scenario coverage
|
||||
- **Coherence**: Track design adherence and pattern consistency
|
||||
|
||||
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
|
||||
|
||||
5. **Verify Completeness**
|
||||
|
||||
**Task Completion**:
|
||||
- If `contextFiles.tasks` exists, read every file path in it
|
||||
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
|
||||
- Count complete vs total tasks
|
||||
- If incomplete tasks exist:
|
||||
- Add CRITICAL issue for each incomplete task
|
||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
||||
|
||||
**Spec Coverage**:
|
||||
- If delta specs exist in `contextFiles.specs`:
|
||||
- Extract all requirements (marked with "### Requirement:")
|
||||
- For each requirement:
|
||||
- Search codebase for keywords related to the requirement
|
||||
- Assess if implementation likely exists
|
||||
- If requirements appear unimplemented:
|
||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
||||
- Recommendation: "Implement requirement X: <description>"
|
||||
|
||||
6. **Verify Correctness**
|
||||
|
||||
**Requirement Implementation Mapping**:
|
||||
- For each requirement from delta specs:
|
||||
- Search codebase for implementation evidence
|
||||
- If found, note file paths and line ranges
|
||||
- Assess if implementation matches requirement intent
|
||||
- If divergence detected:
|
||||
- Add WARNING: "Implementation may diverge from spec: <details>"
|
||||
- Recommendation: "Review <file>:<lines> against requirement X"
|
||||
|
||||
**Scenario Coverage**:
|
||||
- For each scenario in delta specs (marked with "#### Scenario:"):
|
||||
- Check if conditions are handled in code
|
||||
- Check if tests exist covering the scenario
|
||||
- If scenario appears uncovered:
|
||||
- Add WARNING: "Scenario not covered: <scenario name>"
|
||||
- Recommendation: "Add test or implementation for scenario: <description>"
|
||||
|
||||
7. **Verify Coherence**
|
||||
|
||||
**Design Adherence**:
|
||||
- If `contextFiles.design` exists:
|
||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
||||
- Verify implementation follows those decisions
|
||||
- If contradiction detected:
|
||||
- Add WARNING: "Design decision not followed: <decision>"
|
||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
||||
- If no design.md: Skip design adherence check, note "No design.md to verify against"
|
||||
|
||||
**Code Pattern Consistency**:
|
||||
- Review new code for consistency with project patterns
|
||||
- Check file naming, directory structure, coding style
|
||||
- If significant deviations found:
|
||||
- Add SUGGESTION: "Code pattern deviation: <details>"
|
||||
- Recommendation: "Consider following project pattern: <example>"
|
||||
|
||||
8. **Generate Verification Report**
|
||||
|
||||
**Summary Scorecard**:
|
||||
```
|
||||
## Verification Report: <change-name>
|
||||
|
||||
### Summary
|
||||
| Dimension | Status |
|
||||
|--------------|------------------|
|
||||
| Completeness | X/Y tasks, N reqs|
|
||||
| Correctness | M/N reqs covered |
|
||||
| Coherence | Followed/Issues |
|
||||
```
|
||||
|
||||
**Issues by Priority**:
|
||||
|
||||
1. **CRITICAL** (Must fix before archive):
|
||||
- Incomplete tasks
|
||||
- Missing requirement implementations
|
||||
- Each with specific, actionable recommendation
|
||||
|
||||
2. **WARNING** (Should fix):
|
||||
- Spec/design divergences
|
||||
- Missing scenario coverage
|
||||
- Each with specific recommendation
|
||||
|
||||
3. **SUGGESTION** (Nice to fix):
|
||||
- Pattern inconsistencies
|
||||
- Minor improvements
|
||||
- Each with specific recommendation
|
||||
|
||||
**Final Assessment**:
|
||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
|
||||
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If all clear: "All checks passed. Ready for archive."
|
||||
|
||||
**Verification Heuristics**
|
||||
|
||||
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
|
||||
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
|
||||
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
|
||||
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
|
||||
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
|
||||
|
||||
**Graceful Degradation**
|
||||
|
||||
- If only tasks.md exists: verify task completion only, skip spec/design checks
|
||||
- If tasks + specs exist: verify completeness and correctness, skip design
|
||||
- If full artifacts: verify all three dimensions
|
||||
- Always note which checks were skipped and why
|
||||
|
||||
**Output Format**
|
||||
|
||||
Use clear markdown with:
|
||||
- Table for summary scorecard
|
||||
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
|
||||
- Code references in format: `file.ts:123`
|
||||
- Specific, actionable recommendations
|
||||
- No vague suggestions like "consider reviewing"
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copie para .env e ajuste. NUNCA versione o .env real.
|
||||
|
||||
# --- Sessão / segurança ---
|
||||
# Gere com: python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
SECRET_KEY=troque-por-um-valor-aleatorio-longo
|
||||
# Em produção (HTTPS) marque o cookie como seguro:
|
||||
SESSION_HTTPS_ONLY=true
|
||||
|
||||
# --- Login admin (semeado na 1a execucao) ---
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=defina-uma-senha-forte
|
||||
|
||||
# --- OpenAI (opcional; sem chave o app usa OCR/heuristica local) ---
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-4o
|
||||
OPENAI_MAX_PAGES=8
|
||||
|
||||
# --- Banco / uploads ---
|
||||
DB_PATH=data/app.sqlite3
|
||||
MAX_UPLOAD_BYTES=12582912
|
||||
|
||||
# --- Servidor (dev) ---
|
||||
HOST=127.0.0.1
|
||||
PORT=8000
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Segredos e ambiente
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# Banco e dados de runtime
|
||||
data/*.sqlite3
|
||||
data/*.sqlite3-wal
|
||||
data/*.sqlite3-shm
|
||||
data/uploads/
|
||||
data/previews/
|
||||
data/*.log
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
|
||||
# Editores / SO
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -0,0 +1,100 @@
|
||||
# Deploy — VPS Ubuntu + systemd + Nginx + HTTPS
|
||||
|
||||
Guia para publicar o Lernotafiscal em uma VPS Ubuntu (22.04+). O app roda em
|
||||
`127.0.0.1:8000` via Uvicorn sob systemd; o Nginx faz proxy reverso e TLS.
|
||||
|
||||
## 1. Pacotes do sistema
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y python3-venv python3-pip nginx \
|
||||
tesseract-ocr tesseract-ocr-por \
|
||||
certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
> `tesseract-ocr-por` habilita o OCR em português usado no fallback local.
|
||||
> `PyMuPDF` (render de PDF) vem via pip, não precisa de pacote do sistema.
|
||||
|
||||
## 2. Usuário e código
|
||||
|
||||
```bash
|
||||
sudo useradd --system --create-home --home-dir /opt/lernotafiscal lernotafiscal
|
||||
sudo -u lernotafiscal -H bash
|
||||
cd /opt/lernotafiscal
|
||||
git clone <seu-repo> . # ou copie os arquivos do projeto para cá
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 3. Configuração (`.env`)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# gere um SECRET_KEY forte:
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
nano .env
|
||||
```
|
||||
|
||||
Defina no mínimo: `SECRET_KEY`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`,
|
||||
`SESSION_HTTPS_ONLY=true` e, se for usar IA, `OPENAI_API_KEY` (+ `OPENAI_MODEL`).
|
||||
Sem `OPENAI_API_KEY` o app funciona com OCR/heurística local.
|
||||
|
||||
## 4. Dados iniciais (opcional)
|
||||
|
||||
Para migrar o histórico existente (21 notas da skill) para o banco do app:
|
||||
|
||||
```bash
|
||||
.venv/bin/python scripts/migrate_notas.py
|
||||
```
|
||||
|
||||
O banco é criado em `data/app.sqlite3` na primeira execução do app de qualquer forma.
|
||||
|
||||
## 5. Serviço systemd
|
||||
|
||||
```bash
|
||||
sudo cp deploy/lernotafiscal.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now lernotafiscal
|
||||
sudo systemctl status lernotafiscal
|
||||
# a senha temporária (se ADMIN_PASSWORD estiver vazio) aparece no log:
|
||||
sudo journalctl -u lernotafiscal -n 30
|
||||
```
|
||||
|
||||
## 6. Nginx + HTTPS
|
||||
|
||||
```bash
|
||||
sudo cp deploy/nginx.conf /etc/nginx/sites-available/lernotafiscal
|
||||
sudo nano /etc/nginx/sites-available/lernotafiscal # ajuste server_name
|
||||
sudo ln -s /etc/nginx/sites-available/lernotafiscal /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
sudo certbot --nginx -d seu.dominio.com # emite e configura o TLS
|
||||
```
|
||||
|
||||
Após o certbot, confirme que o `.env` tem `SESSION_HTTPS_ONLY=true` e reinicie:
|
||||
`sudo systemctl restart lernotafiscal`.
|
||||
|
||||
## 7. Atualizações
|
||||
|
||||
```bash
|
||||
sudo -u lernotafiscal -H bash -c 'cd /opt/lernotafiscal && git pull && .venv/bin/pip install -r requirements.txt'
|
||||
sudo systemctl restart lernotafiscal
|
||||
```
|
||||
|
||||
## 8. Backup
|
||||
|
||||
O estado vive em dois lugares — faça backup dos dois (ex.: cron diário):
|
||||
|
||||
```bash
|
||||
# banco (checkpoint do WAL antes de copiar)
|
||||
sqlite3 /opt/lernotafiscal/data/app.sqlite3 "PRAGMA wal_checkpoint(TRUNCATE);"
|
||||
cp /opt/lernotafiscal/data/app.sqlite3 /backup/app-$(date +%F).sqlite3
|
||||
# arquivos enviados
|
||||
tar czf /backup/uploads-$(date +%F).tgz -C /opt/lernotafiscal/data uploads
|
||||
```
|
||||
|
||||
## Notas de segurança
|
||||
|
||||
- Os arquivos enviados **nunca** são servidos por static mount; o download passa
|
||||
pela rota autenticada `GET /files/{id}`.
|
||||
- Segredos ficam só no `.env` (fora do git via `.gitignore`).
|
||||
- Rode o Uvicorn apenas em `127.0.0.1` — a exposição pública é só via Nginx/TLS.
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=8000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Dependências de sistema para OCR em português
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-por \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Instala dependências Python primeiro para aproveitar cache de build
|
||||
COPY requirements.txt ./
|
||||
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install -r requirements.txt
|
||||
|
||||
# Copia o restante da aplicação
|
||||
COPY . .
|
||||
|
||||
# Prepara diretórios persistentes e usuário sem privilégios
|
||||
RUN mkdir -p /app/data /app/data/uploads \
|
||||
&& useradd --system --uid 10001 --create-home \
|
||||
--home-dir /home/lernotafiscal lernotafiscal \
|
||||
&& chown -R lernotafiscal:lernotafiscal /app
|
||||
|
||||
USER lernotafiscal
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# APP_MODULE pode ser sobrescrito pelo Coolify/docker-compose.
|
||||
# Padrão assumido: app.main:app
|
||||
CMD ["sh", "-c", "uvicorn ${APP_MODULE:-app.main:app} --host ${HOST:-0.0.0.0} --port ${PORT:-8000} --proxy-headers --forwarded-allow-ips='*'"]
|
||||
@@ -0,0 +1,69 @@
|
||||
# Lernotafiscal
|
||||
|
||||
Aplicativo web para importar notas e cupons fiscais (PDF/imagem), revisar a
|
||||
extração e acompanhar despesas confirmadas. A leitura dos documentos usa
|
||||
**OpenAI Vision** quando há chave configurada, com **fallback** para OCR/heurística
|
||||
local. Feito para rodar numa VPS.
|
||||
|
||||
## Recursos
|
||||
|
||||
- Login simples (usuário/senha, hash bcrypt, sessão por cookie assinado).
|
||||
- Upload de PDF/JPG/PNG com **confirmação em lote** antes de gravar: mostra o
|
||||
número de documentos e o valor total, e destaca itens ilegíveis para correção.
|
||||
- Data ilegível/ausente → **1º dia do mês corrente** (regra única compartilhada
|
||||
por IA e cadastro manual).
|
||||
- **CRUD** completo de documentos (criar, editar, excluir, listar/filtrar).
|
||||
- Dashboard com KPIs, gastos por mês e por fornecedor.
|
||||
- Tema claro/escuro (persistido no navegador).
|
||||
- Banco **SQLite** (WAL), sem serviço externo.
|
||||
|
||||
## Stack
|
||||
|
||||
FastAPI + Uvicorn · Jinja2 (server-rendered) · SQLite · OpenAI (Vision) com
|
||||
fallback OCR (PyMuPDF + Tesseract) · bcrypt.
|
||||
|
||||
## Rodar localmente
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv/Scripts/activate # Windows; no Linux/Mac: source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # defina ADMIN_PASSWORD e SECRET_KEY
|
||||
python app.py # http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
Sem `OPENAI_API_KEY` o app usa OCR/heurística local (o Tesseract precisa estar
|
||||
instalado no sistema para OCR de imagens). Com a chave, a extração usa a IA.
|
||||
|
||||
### Migrar histórico (opcional)
|
||||
|
||||
```bash
|
||||
python scripts/migrate_notas.py # importa Skill/dados/lernotafiscal.db -> app
|
||||
```
|
||||
|
||||
## Testes
|
||||
|
||||
```bash
|
||||
python -m unittest
|
||||
```
|
||||
|
||||
## Deploy em VPS
|
||||
|
||||
Guia completo (Ubuntu + systemd + Nginx + HTTPS) em [DEPLOY.md](DEPLOY.md).
|
||||
|
||||
## Estrutura
|
||||
|
||||
```
|
||||
app/ aplicação FastAPI (config, database, auth, rotas, templates)
|
||||
app/ai_extraction extração via OpenAI Vision
|
||||
app/ingestion orquestra IA -> fallback OCR local
|
||||
lernotafiscal/ motor de extração heurística/OCR reutilizado no fallback
|
||||
scripts/ utilitários (migrate_notas.py)
|
||||
deploy/ unit systemd + config nginx
|
||||
```
|
||||
|
||||
## Variáveis de ambiente
|
||||
|
||||
Veja [.env.example](.env.example). Principais: `SECRET_KEY`, `ADMIN_USERNAME`,
|
||||
`ADMIN_PASSWORD`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `DB_PATH`, `MAX_UPLOAD_BYTES`,
|
||||
`SESSION_HTTPS_ONLY`.
|
||||
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
"""Entrypoint de desenvolvimento: sobe o app FastAPI com Uvicorn.
|
||||
|
||||
Produção usa `uvicorn app.main:app` via systemd (veja DEPLOY.md).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=os.environ.get("HOST", "127.0.0.1"),
|
||||
port=int(os.environ.get("PORT", "8000")),
|
||||
reload=bool(os.environ.get("RELOAD")),
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Lernotafiscal — aplicativo web de controle de despesas fiscais."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Extração de documentos fiscais via OpenAI Vision, com fallback local.
|
||||
|
||||
`extract_with_ai` recebe as imagens de um arquivo enviado (páginas de PDF já
|
||||
renderizadas em PNG, ou a própria imagem) e devolve uma lista de `RawExtraction`
|
||||
— ou `None` para sinalizar que o chamador deve cair no OCR/heurística local.
|
||||
|
||||
A superfície da SDK usada (chat.completions + response_format json_object +
|
||||
entrada de imagem por data URI) foi verificada contra `openai` 2.x.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from .config import get_openai_client, get_settings
|
||||
|
||||
logger = logging.getLogger("lernotafiscal.ai")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RawExtraction:
|
||||
"""Resultado bruto de UM documento fiscal, antes de normalizar a data."""
|
||||
|
||||
supplier_name: str | None
|
||||
purchase_date_raw: str | None
|
||||
total_paid: float | None
|
||||
legible: bool
|
||||
uncertain_fields: list[str] = field(default_factory=list)
|
||||
extractor: str = "openai"
|
||||
raw_text: str = ""
|
||||
|
||||
|
||||
_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg"}
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"Você é um extrator de dados de notas e cupons fiscais brasileiros. "
|
||||
"Responda SEMPRE em JSON válido, sem texto fora do JSON."
|
||||
)
|
||||
|
||||
# Tolerância de plausibilidade da data extraída (ver `_is_plausible_purchase_date`).
|
||||
# A checagem opera em granularidade de mês/ano — é isso que de fato importa
|
||||
# para a competência persistida, o dia é descartado na normalização.
|
||||
_DATE_TOLERANCE_YEARS_PAST = 2
|
||||
_DATE_TOLERANCE_MONTHS_FUTURE = 1
|
||||
|
||||
|
||||
def _user_prompt() -> str:
|
||||
today = date.today().isoformat()
|
||||
return f"""A data de hoje é {today}. As imagens a seguir são documentos fiscais (notas, cupons, recibos). Podem conter mais de um documento.
|
||||
Para CADA documento distinto, extraia:
|
||||
- "fornecedor": razão social ou nome fantasia do emissor (o mais destacado no cabeçalho), ou null.
|
||||
- "data_compra": data da compra no formato "YYYY-MM-DD", ou null se ilegível/ausente. Esses documentos são quase sempre recentes (deste ano ou do ano anterior a {today[:4]}) — releia com cuidado os dois últimos dígitos do ano antes de responder, para não confundir dígitos parecidos (ex.: não troque "26" por "22").
|
||||
- "valor_pago": número (ponto decimal) do TOTAL efetivamente pago. Use o "VALOR TOTAL"/"TOTAL A PAGAR"/"VALOR PAGO". NUNCA use "TROCO" nem "DINHEIRO RECEBIDO". Se parcelado, use o total da compra. Null se ilegível.
|
||||
- "legivel": true se você leu os campos com confiança; false se o documento está borrado, cortado ou ilegível.
|
||||
- "campos_incertos": lista dos campos que ficaram duvidosos (ex.: ["data_compra","valor_pago"]).
|
||||
|
||||
Responda exatamente neste formato:
|
||||
{{"documentos": [{{"fornecedor": ..., "data_compra": ..., "valor_pago": ..., "legivel": ..., "campos_incertos": [...]}}]}}
|
||||
Se nenhum documento fiscal for identificável, responda {{"documentos": []}}."""
|
||||
|
||||
|
||||
def _is_plausible_purchase_date(date_raw: str) -> bool:
|
||||
"""Sanidade sobre a competência (mês/ano) da data devolvida pela IA: notas
|
||||
fiscais são quase sempre recentes, então uma competência muito no passado
|
||||
ou no futuro é sinal de erro de leitura de dígito (ex.: "26" lido como
|
||||
"22") — melhor mandar para revisão manual do que aceitar silenciosamente."""
|
||||
try:
|
||||
year, month, day = (int(part) for part in date_raw.split("-"))
|
||||
date(year, month, day) # valida o calendário
|
||||
except (ValueError, TypeError):
|
||||
return True # formato inesperado já é tratado como campo incerto à parte
|
||||
today = date.today()
|
||||
months_ahead = (year * 12 + month) - (today.year * 12 + today.month)
|
||||
if months_ahead > _DATE_TOLERANCE_MONTHS_FUTURE:
|
||||
return False
|
||||
if year < today.year - _DATE_TOLERANCE_YEARS_PAST:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _data_uri(path: Path) -> str | None:
|
||||
mime = _MIME.get(path.suffix.lower())
|
||||
if not mime:
|
||||
return None
|
||||
try:
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
except OSError:
|
||||
return None
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
|
||||
|
||||
def _coerce_float(value: object) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return round(float(value), 2)
|
||||
text = str(value).strip().replace("R$", "").replace(" ", "")
|
||||
if not text:
|
||||
return None
|
||||
# aceita "1.234,56" e "1234.56"
|
||||
if "," in text and "." in text:
|
||||
text = text.replace(".", "").replace(",", ".")
|
||||
elif "," in text:
|
||||
text = text.replace(",", ".")
|
||||
try:
|
||||
return round(float(text), 2)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_response(content: str) -> list[RawExtraction]:
|
||||
data = json.loads(content)
|
||||
docs = data.get("documentos") if isinstance(data, dict) else None
|
||||
if not isinstance(docs, list):
|
||||
return []
|
||||
results: list[RawExtraction] = []
|
||||
for item in docs:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
supplier = item.get("fornecedor")
|
||||
supplier = str(supplier).strip()[:200] if supplier else None
|
||||
date_raw = item.get("data_compra")
|
||||
date_raw = str(date_raw).strip() if date_raw else None
|
||||
total = _coerce_float(item.get("valor_pago"))
|
||||
legible = bool(item.get("legivel", True))
|
||||
uncertain = item.get("campos_incertos") or []
|
||||
uncertain = [str(x) for x in uncertain if x] if isinstance(uncertain, list) else []
|
||||
# segurança: campos faltando são inerentemente incertos
|
||||
for name, val in (("fornecedor", supplier), ("data_compra", date_raw), ("valor_pago", total)):
|
||||
if val is None and name not in uncertain:
|
||||
uncertain.append(name)
|
||||
# segurança: data implausível (ano muito no passado/futuro) força revisão
|
||||
# manual, mesmo que a IA tenha respondido "legivel": true — ver
|
||||
# `_is_plausible_purchase_date` para o porquê (erro de leitura de dígito).
|
||||
if date_raw is not None and not _is_plausible_purchase_date(date_raw):
|
||||
if "data_compra" not in uncertain:
|
||||
uncertain.append("data_compra")
|
||||
legible = False
|
||||
if uncertain and legible and len(uncertain) >= 2:
|
||||
legible = False
|
||||
results.append(
|
||||
RawExtraction(
|
||||
supplier_name=supplier,
|
||||
purchase_date_raw=date_raw,
|
||||
total_paid=total,
|
||||
legible=legible,
|
||||
uncertain_fields=uncertain,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def extract_with_ai(image_paths: list[Path]) -> list[RawExtraction] | None:
|
||||
"""Extrai via OpenAI. Retorna None se IA indisponível ou em erro (=> fallback)."""
|
||||
settings = get_settings()
|
||||
if not settings.openai_enabled:
|
||||
return None
|
||||
|
||||
uris = [uri for p in image_paths[: settings.openai_max_pages] if (uri := _data_uri(p))]
|
||||
if not uris:
|
||||
return None
|
||||
|
||||
try:
|
||||
client = get_openai_client()
|
||||
content: list[dict] = [{"type": "text", "text": _user_prompt()}]
|
||||
for uri in uris:
|
||||
content.append({"type": "image_url", "image_url": {"url": uri}})
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=settings.openai_model,
|
||||
messages=[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0,
|
||||
)
|
||||
message = response.choices[0].message.content or "{}"
|
||||
return _parse_response(message)
|
||||
except Exception as exc: # rede, cota, parsing, modelo indisponível...
|
||||
logger.warning("Extração OpenAI falhou, usando fallback local: %s", exc)
|
||||
return None
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
"""Autenticação: hash de senha (bcrypt), seed do admin, CSRF e guarda de sessão."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import bcrypt
|
||||
from starlette.requests import Request
|
||||
|
||||
from . import database as db
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Senhas
|
||||
# --------------------------------------------------------------------------- #
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def seed_admin() -> str | None:
|
||||
"""Cria o usuário admin na 1ª execução. Retorna uma mensagem de aviso, se houver."""
|
||||
settings = get_settings()
|
||||
with db.session() as conn:
|
||||
if db.count_users(conn) > 0:
|
||||
return None
|
||||
password = settings.admin_password
|
||||
note = None
|
||||
if not password:
|
||||
# Sem ADMIN_PASSWORD definido: gera uma senha aleatória e a expõe UMA vez
|
||||
# no log para o operador. Em produção defina ADMIN_PASSWORD no ambiente.
|
||||
password = secrets.token_urlsafe(12)
|
||||
note = (
|
||||
f"[AVISO] Nenhum ADMIN_PASSWORD definido. Usuário '{settings.admin_username}' "
|
||||
f"criado com senha temporária: {password} (defina ADMIN_PASSWORD e reinicie)"
|
||||
)
|
||||
db.create_user(conn, settings.admin_username, hash_password(password))
|
||||
return note
|
||||
|
||||
|
||||
def authenticate(username: str, password: str) -> bool:
|
||||
with db.session() as conn:
|
||||
row = db.get_user(conn, username)
|
||||
if row is None:
|
||||
# Compara mesmo sem usuário para não vazar tempo (mitiga user enumeration).
|
||||
verify_password(password, hash_password("dummy"))
|
||||
return False
|
||||
return verify_password(password, row["password_hash"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Sessão / guarda
|
||||
# --------------------------------------------------------------------------- #
|
||||
def current_user(request: Request) -> str | None:
|
||||
return request.session.get("user")
|
||||
|
||||
|
||||
def login_session(request: Request, username: str) -> None:
|
||||
request.session["user"] = username
|
||||
request.session["logged_at"] = int(time.time())
|
||||
# rotaciona o token CSRF a cada login
|
||||
request.session["csrf"] = secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def logout_session(request: Request) -> None:
|
||||
request.session.clear()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CSRF
|
||||
# --------------------------------------------------------------------------- #
|
||||
def get_csrf_token(request: Request) -> str:
|
||||
token = request.session.get("csrf")
|
||||
if not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
request.session["csrf"] = token
|
||||
return token
|
||||
|
||||
|
||||
def check_csrf(request: Request, submitted: str | None) -> bool:
|
||||
expected = request.session.get("csrf")
|
||||
return bool(expected) and bool(submitted) and secrets.compare_digest(expected, submitted)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Categorização automática de notas fiscais por fornecedor.
|
||||
|
||||
Ordem de resolução, sempre retornando um `categoria.id` válido (nunca `None`):
|
||||
cache em memória -> limite de chamadas de IA por lote -> IA -> palavra-chave
|
||||
-> categoria reservada `1` ("Não Encontrado").
|
||||
|
||||
Sem retry: qualquer falha na chamada de IA é tratada como "sem categoria da IA"
|
||||
e o fluxo cai para o próximo passo, sem nunca impedir a criação do documento.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from . import database as db
|
||||
from .config import get_openai_client, get_settings
|
||||
|
||||
logger = logging.getLogger("lernotafiscal.categorization")
|
||||
|
||||
# Cache em memória (por fornecedor normalizado) e contador de chamadas de IA
|
||||
# por lote — ambos módulo-level, aceitável pois degradam de forma segura
|
||||
# (pior caso: recategoriza após restart, ainda limitado pelo teto por lote).
|
||||
_supplier_cache: dict[str, int] = {}
|
||||
_batch_ai_calls: dict[int, int] = {}
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Limpa cache e contadores em memória. Uso principal: isolamento em testes."""
|
||||
_supplier_cache.clear()
|
||||
_batch_ai_calls.clear()
|
||||
|
||||
|
||||
def _normalize(supplier_name: str) -> str:
|
||||
return (supplier_name or "").strip().upper()
|
||||
|
||||
|
||||
def _call_ai(supplier_name: str, known_categories: list[str]) -> str | None:
|
||||
"""Chamada única (sem retry) à IA para sugerir uma categoria dentre as
|
||||
conhecidas. Qualquer erro ou valor fora do conjunto conhecido -> None."""
|
||||
settings = get_settings()
|
||||
if not settings.openai_enabled or not known_categories:
|
||||
return None
|
||||
try:
|
||||
client = get_openai_client()
|
||||
prompt = (
|
||||
"Classifique o fornecedor de uma nota fiscal brasileira em UMA das "
|
||||
"categorias a seguir (responda exatamente como escrito na lista): "
|
||||
+ ", ".join(known_categories)
|
||||
+ f'\nFornecedor: "{supplier_name}"\n'
|
||||
'Responda em JSON: {"categoria": "<categoria da lista>"} '
|
||||
'ou {"categoria": null} se nenhuma categoria se aplicar claramente.'
|
||||
)
|
||||
response = client.chat.completions.create(
|
||||
model=settings.openai_model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Você classifica fornecedores de notas fiscais brasileiras por categoria. Responda SEMPRE em JSON válido.",
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0,
|
||||
)
|
||||
content = response.choices[0].message.content or "{}"
|
||||
data = json.loads(content)
|
||||
category = data.get("categoria") if isinstance(data, dict) else None
|
||||
if isinstance(category, str) and category.strip() in known_categories:
|
||||
return category.strip()
|
||||
return None
|
||||
except Exception as exc: # rede, cota, parsing, modelo indisponível...
|
||||
logger.warning("Categorização por IA falhou para '%s': %s", supplier_name, exc)
|
||||
return None
|
||||
|
||||
|
||||
def categorize_supplier(supplier_name: str, conn: sqlite3.Connection, batch_id: int) -> int:
|
||||
"""Determina o `categoria.id` de um fornecedor. Nunca levanta exceção para o
|
||||
chamador nem retorna `None` — em último caso devolve a categoria reservada."""
|
||||
try:
|
||||
normalized = _normalize(supplier_name)
|
||||
|
||||
cached = _supplier_cache.get(normalized)
|
||||
if cached is not None:
|
||||
logger.debug(
|
||||
"Categorização de '%s' pulada (cache hit) -> categoria_id=%s", supplier_name, cached
|
||||
)
|
||||
return cached
|
||||
|
||||
settings = get_settings()
|
||||
categorias = db.list_categorias(conn)
|
||||
known_names: list[str] = []
|
||||
id_by_name: dict[str, int] = {}
|
||||
for row in categorias:
|
||||
if row["id"] == db.RESERVED_CATEGORIA_ID:
|
||||
continue
|
||||
known_names.append(row["categoria"])
|
||||
id_by_name.setdefault(row["categoria"], int(row["id"]))
|
||||
|
||||
categoria_id: int | None = None
|
||||
calls_so_far = _batch_ai_calls.get(batch_id, 0)
|
||||
limit = settings.categorization_max_ai_calls_per_batch
|
||||
|
||||
if not settings.openai_enabled:
|
||||
pass # sem IA configurada: cai direto para palavra-chave/reservado
|
||||
elif calls_so_far >= limit:
|
||||
logger.info(
|
||||
"Categorização de '%s' pulada (limite de %s chamadas de IA por lote atingido no lote %s)",
|
||||
supplier_name, limit, batch_id,
|
||||
)
|
||||
else:
|
||||
_batch_ai_calls[batch_id] = calls_so_far + 1
|
||||
ai_category = _call_ai(supplier_name, known_names)
|
||||
if ai_category is not None:
|
||||
categoria_id = id_by_name.get(ai_category)
|
||||
|
||||
if categoria_id is None:
|
||||
match = db.find_categoria_by_keyword(conn, supplier_name)
|
||||
if match is not None:
|
||||
categoria_id = int(match["id"])
|
||||
|
||||
if categoria_id is None:
|
||||
categoria_id = db.RESERVED_CATEGORIA_ID
|
||||
|
||||
_supplier_cache[normalized] = categoria_id
|
||||
return categoria_id
|
||||
except Exception as exc: # nunca impede a criação do fiscal_documents
|
||||
logger.warning("Categorização falhou inesperadamente para '%s': %s", supplier_name, exc)
|
||||
return db.RESERVED_CATEGORIA_ID
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Configuração central lida de variáveis de ambiente (com suporte a .env)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
try: # carregamento opcional do .env
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
except Exception: # pragma: no cover - dotenv é opcional
|
||||
pass
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||
PREVIEW_DIR = DATA_DIR / "previews"
|
||||
|
||||
ALLOWED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png"}
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on", "sim"}
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Configuração resolvida uma vez no start do processo."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_dir = BASE_DIR
|
||||
self.data_dir = DATA_DIR
|
||||
self.upload_dir = UPLOAD_DIR
|
||||
self.preview_dir = PREVIEW_DIR
|
||||
|
||||
self.db_path = Path(os.environ.get("DB_PATH", DATA_DIR / "app.sqlite3"))
|
||||
|
||||
# Segredo da sessão. Em produção DEVE vir do ambiente; sem ele geramos um
|
||||
# efêmero (as sessões caem a cada restart) e avisamos no log.
|
||||
self.secret_key = os.environ.get("SECRET_KEY", "")
|
||||
self.secret_key_is_ephemeral = not self.secret_key
|
||||
if self.secret_key_is_ephemeral:
|
||||
self.secret_key = secrets.token_urlsafe(48)
|
||||
|
||||
# OpenAI (opcional — sem chave o app cai no OCR/heurística local).
|
||||
self.openai_api_key = os.environ.get("OPENAI_API_KEY", "").strip()
|
||||
self.openai_model = os.environ.get("OPENAI_MODEL", "gpt-4o").strip() or "gpt-4o"
|
||||
self.openai_max_pages = int(os.environ.get("OPENAI_MAX_PAGES", "8"))
|
||||
|
||||
# Admin semeado na primeira execução.
|
||||
self.admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
|
||||
self.admin_password = os.environ.get("ADMIN_PASSWORD", "").strip()
|
||||
|
||||
self.max_upload_bytes = int(os.environ.get("MAX_UPLOAD_BYTES", str(12 * 1024 * 1024)))
|
||||
self.allowed_extensions = set(ALLOWED_EXTENSIONS)
|
||||
|
||||
self.session_cookie = os.environ.get("SESSION_COOKIE", "lnf_session")
|
||||
self.session_https_only = _env_bool("SESSION_HTTPS_ONLY", False)
|
||||
self.session_max_age = int(os.environ.get("SESSION_MAX_AGE", str(60 * 60 * 12)))
|
||||
|
||||
# Limite de segurança: máximo de chamadas de IA de categorização por lote de importação.
|
||||
self.categorization_max_ai_calls_per_batch = int(
|
||||
os.environ.get("CATEGORIZATION_MAX_AI_CALLS_PER_BATCH", "50")
|
||||
)
|
||||
|
||||
@property
|
||||
def openai_enabled(self) -> bool:
|
||||
return bool(self.openai_api_key)
|
||||
|
||||
def ensure_storage(self) -> None:
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
settings = Settings()
|
||||
settings.ensure_storage()
|
||||
return settings
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_openai_client():
|
||||
"""Client OpenAI reaproveitado entre chamadas (evita recriar o pool HTTP a cada request).
|
||||
|
||||
Só deve ser chamado quando `settings.openai_enabled` for True.
|
||||
"""
|
||||
from openai import OpenAI
|
||||
|
||||
return OpenAI(api_key=get_settings().openai_api_key)
|
||||
+876
@@ -0,0 +1,876 @@
|
||||
"""Acesso ao SQLite: esquema, migrações leves e helpers de consulta.
|
||||
|
||||
Estende o esquema original (uploaded_files -> detected_documents -> fiscal_documents)
|
||||
com autenticação (users), lotes de importação (import_batches) e os campos de
|
||||
legibilidade usados pela extração por IA.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_batches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
confirmed_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uploaded_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id INTEGER REFERENCES import_batches(id) ON DELETE SET NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
detected_count INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS detected_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
upload_id INTEGER NOT NULL REFERENCES uploaded_files(id) ON DELETE CASCADE,
|
||||
batch_id INTEGER REFERENCES import_batches(id) ON DELETE SET NULL,
|
||||
source_file_name TEXT NOT NULL,
|
||||
source_page INTEGER,
|
||||
source_location TEXT NOT NULL,
|
||||
raw_text TEXT NOT NULL DEFAULT '',
|
||||
mes INTEGER,
|
||||
ano INTEGER,
|
||||
supplier_name TEXT,
|
||||
total_paid REAL,
|
||||
confidence TEXT NOT NULL DEFAULT 'low',
|
||||
field_confidence_json TEXT NOT NULL DEFAULT '{}',
|
||||
legible INTEGER NOT NULL DEFAULT 1,
|
||||
uncertain_fields TEXT NOT NULL DEFAULT '[]',
|
||||
extractor TEXT NOT NULL DEFAULT 'local',
|
||||
status TEXT NOT NULL DEFAULT 'staged',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categoria (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
categoria TEXT NOT NULL,
|
||||
palavra_chave TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fiscal_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
detected_document_id INTEGER REFERENCES detected_documents(id) ON DELETE SET NULL,
|
||||
source_file_name TEXT NOT NULL DEFAULT '',
|
||||
source_location TEXT NOT NULL DEFAULT '',
|
||||
mes INTEGER NOT NULL,
|
||||
ano INTEGER NOT NULL,
|
||||
supplier_name TEXT NOT NULL,
|
||||
total_paid REAL NOT NULL,
|
||||
confidence TEXT NOT NULL DEFAULT 'high',
|
||||
categoria_id INTEGER REFERENCES categoria(id),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fiscal_competencia ON fiscal_documents(ano, mes);
|
||||
CREATE INDEX IF NOT EXISTS idx_detected_batch ON detected_documents(batch_id, status);
|
||||
"""
|
||||
|
||||
_RESERVED_CATEGORIA_SEED = (
|
||||
"INSERT OR IGNORE INTO categoria (id, categoria, palavra_chave) VALUES (1, 'Não Encontrado', '')"
|
||||
)
|
||||
|
||||
|
||||
def connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||
settings = get_settings()
|
||||
settings.ensure_storage()
|
||||
conn = sqlite3.connect(db_path or settings.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def _table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||
return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
|
||||
|
||||
|
||||
def _rebuild_fiscal_documents(conn: sqlite3.Connection, old_columns: set[str]) -> None:
|
||||
"""Recria `fiscal_documents` com `mes`/`ano` no lugar de `purchase_date`.
|
||||
|
||||
SQLite não suporta `DROP COLUMN` em todas as versões-alvo, então o rebuild
|
||||
(tabela nova + `INSERT ... SELECT` + `DROP` + `RENAME`) é a técnica
|
||||
portável recomendada pela própria documentação do SQLite.
|
||||
"""
|
||||
categoria_expr = "categoria_id" if "categoria_id" in old_columns else "NULL"
|
||||
conn.execute("DROP TABLE IF EXISTS fiscal_documents_new")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE fiscal_documents_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
detected_document_id INTEGER REFERENCES detected_documents(id) ON DELETE SET NULL,
|
||||
source_file_name TEXT NOT NULL DEFAULT '',
|
||||
source_location TEXT NOT NULL DEFAULT '',
|
||||
mes INTEGER NOT NULL,
|
||||
ano INTEGER NOT NULL,
|
||||
supplier_name TEXT NOT NULL,
|
||||
total_paid REAL NOT NULL,
|
||||
confidence TEXT NOT NULL DEFAULT 'high',
|
||||
categoria_id INTEGER REFERENCES categoria(id),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT INTO fiscal_documents_new (
|
||||
id, detected_document_id, source_file_name, source_location,
|
||||
mes, ano, supplier_name, total_paid, confidence, categoria_id,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT id, detected_document_id, source_file_name, source_location,
|
||||
CAST(substr(purchase_date, 6, 2) AS INTEGER),
|
||||
CAST(substr(purchase_date, 1, 4) AS INTEGER),
|
||||
supplier_name, total_paid, confidence, {categoria_expr},
|
||||
created_at, updated_at
|
||||
FROM fiscal_documents
|
||||
"""
|
||||
)
|
||||
conn.execute("DROP TABLE fiscal_documents")
|
||||
conn.execute("ALTER TABLE fiscal_documents_new RENAME TO fiscal_documents")
|
||||
|
||||
|
||||
def _rebuild_detected_documents(conn: sqlite3.Connection) -> None:
|
||||
"""Mesmo rebuild que `_rebuild_fiscal_documents`, mas `mes`/`ano` ficam
|
||||
nullable — mesmo comportamento opcional que `purchase_date` tinha."""
|
||||
conn.execute("DROP TABLE IF EXISTS detected_documents_new")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE detected_documents_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
upload_id INTEGER NOT NULL REFERENCES uploaded_files(id) ON DELETE CASCADE,
|
||||
batch_id INTEGER REFERENCES import_batches(id) ON DELETE SET NULL,
|
||||
source_file_name TEXT NOT NULL,
|
||||
source_page INTEGER,
|
||||
source_location TEXT NOT NULL,
|
||||
raw_text TEXT NOT NULL DEFAULT '',
|
||||
mes INTEGER,
|
||||
ano INTEGER,
|
||||
supplier_name TEXT,
|
||||
total_paid REAL,
|
||||
confidence TEXT NOT NULL DEFAULT 'low',
|
||||
field_confidence_json TEXT NOT NULL DEFAULT '{}',
|
||||
legible INTEGER NOT NULL DEFAULT 1,
|
||||
uncertain_fields TEXT NOT NULL DEFAULT '[]',
|
||||
extractor TEXT NOT NULL DEFAULT 'local',
|
||||
status TEXT NOT NULL DEFAULT 'staged',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO detected_documents_new (
|
||||
id, upload_id, batch_id, source_file_name, source_page, source_location,
|
||||
raw_text, mes, ano, supplier_name, total_paid, confidence,
|
||||
field_confidence_json, legible, uncertain_fields, extractor, status,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT id, upload_id, batch_id, source_file_name, source_page, source_location,
|
||||
raw_text,
|
||||
CASE WHEN purchase_date IS NOT NULL THEN CAST(substr(purchase_date, 6, 2) AS INTEGER) END,
|
||||
CASE WHEN purchase_date IS NOT NULL THEN CAST(substr(purchase_date, 1, 4) AS INTEGER) END,
|
||||
supplier_name, total_paid, confidence,
|
||||
field_confidence_json, legible, uncertain_fields, extractor, status,
|
||||
created_at, updated_at
|
||||
FROM detected_documents
|
||||
"""
|
||||
)
|
||||
conn.execute("DROP TABLE detected_documents")
|
||||
conn.execute("ALTER TABLE detected_documents_new RENAME TO detected_documents")
|
||||
|
||||
|
||||
def _migrate_competencia(conn: sqlite3.Connection) -> None:
|
||||
"""Migra `fiscal_documents`/`detected_documents` de `purchase_date` para
|
||||
`mes`/`ano`, uma única vez. Guardado por `PRAGMA table_info`: só roda se
|
||||
`purchase_date` ainda existir (tabela de instalação anterior a esta
|
||||
mudança); em uma instalação nova, ou já migrada, é um no-op."""
|
||||
fiscal_columns = _table_columns(conn, "fiscal_documents")
|
||||
detected_columns = _table_columns(conn, "detected_documents")
|
||||
needs_fiscal = "purchase_date" in fiscal_columns
|
||||
needs_detected = "purchase_date" in detected_columns
|
||||
if not needs_fiscal and not needs_detected:
|
||||
return
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
if needs_fiscal:
|
||||
_rebuild_fiscal_documents(conn, fiscal_columns)
|
||||
if needs_detected:
|
||||
_rebuild_detected_documents(conn)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
else:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def init_db(conn: sqlite3.Connection) -> None:
|
||||
_migrate_competencia(conn)
|
||||
conn.executescript(SCHEMA)
|
||||
columns = {row["name"] for row in conn.execute("PRAGMA table_info(fiscal_documents)")}
|
||||
if "categoria_id" not in columns:
|
||||
conn.execute("ALTER TABLE fiscal_documents ADD COLUMN categoria_id INTEGER REFERENCES categoria(id)")
|
||||
conn.execute(_RESERVED_CATEGORIA_SEED)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session(db_path: Path | None = None) -> Iterator[sqlite3.Connection]:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
init_db(conn)
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Usuários
|
||||
# --------------------------------------------------------------------------- #
|
||||
def count_users(conn: sqlite3.Connection) -> int:
|
||||
return int(conn.execute("SELECT COUNT(*) FROM users").fetchone()[0])
|
||||
|
||||
|
||||
def get_user(conn: sqlite3.Connection, username: str) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM users WHERE username = ?", (username,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def create_user(conn: sqlite3.Connection, username: str, password_hash: str) -> int:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||
(username, password_hash),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_password(conn: sqlite3.Connection, username: str, password_hash: str) -> None:
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash = ? WHERE username = ?",
|
||||
(password_hash, username),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Lotes de importação
|
||||
# --------------------------------------------------------------------------- #
|
||||
def create_batch(conn: sqlite3.Connection) -> int:
|
||||
cur = conn.execute("INSERT INTO import_batches (status) VALUES ('open')")
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_batch(conn: sqlite3.Connection, batch_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM import_batches WHERE id = ?", (batch_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def set_batch_status(conn: sqlite3.Connection, batch_id: int, status: str) -> None:
|
||||
if status == "confirmed":
|
||||
conn.execute(
|
||||
"UPDATE import_batches SET status = ?, confirmed_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(status, batch_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE import_batches SET status = ? WHERE id = ?", (status, batch_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Uploads
|
||||
# --------------------------------------------------------------------------- #
|
||||
def insert_upload(
|
||||
conn: sqlite3.Connection,
|
||||
batch_id: int,
|
||||
original_name: str,
|
||||
stored_path: Path,
|
||||
content_type: str,
|
||||
size_bytes: int,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> int:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO uploaded_files (batch_id, original_name, stored_path, content_type, size_bytes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(batch_id, original_name, str(stored_path), content_type, size_bytes),
|
||||
)
|
||||
if commit:
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_upload_status(
|
||||
conn: sqlite3.Connection,
|
||||
upload_id: int,
|
||||
status: str,
|
||||
detected_count: int,
|
||||
message: str | None = None,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE uploaded_files
|
||||
SET status = ?, detected_count = ?, message = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, detected_count, message, upload_id),
|
||||
)
|
||||
if commit:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_upload(conn: sqlite3.Connection, upload_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM uploaded_files WHERE id = ?", (upload_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Documentos detectados (staging)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def insert_detected(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
upload_id: int,
|
||||
batch_id: int,
|
||||
source_file_name: str,
|
||||
source_page: int | None,
|
||||
source_location: str,
|
||||
raw_text: str,
|
||||
mes: int | None,
|
||||
ano: int | None,
|
||||
supplier_name: str | None,
|
||||
total_paid: float | None,
|
||||
confidence: str,
|
||||
field_confidence: dict[str, str],
|
||||
legible: bool,
|
||||
uncertain_fields: list[str],
|
||||
extractor: str,
|
||||
commit: bool = True,
|
||||
) -> int:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO detected_documents (
|
||||
upload_id, batch_id, source_file_name, source_page, source_location,
|
||||
raw_text, mes, ano, supplier_name, total_paid, confidence,
|
||||
field_confidence_json, legible, uncertain_fields, extractor, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'staged')
|
||||
""",
|
||||
(
|
||||
upload_id,
|
||||
batch_id,
|
||||
source_file_name,
|
||||
source_page,
|
||||
source_location,
|
||||
raw_text,
|
||||
mes,
|
||||
ano,
|
||||
supplier_name,
|
||||
total_paid,
|
||||
confidence,
|
||||
json.dumps(field_confidence, ensure_ascii=False),
|
||||
1 if legible else 0,
|
||||
json.dumps(uncertain_fields, ensure_ascii=False),
|
||||
extractor,
|
||||
),
|
||||
)
|
||||
if commit:
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_detected(conn: sqlite3.Connection, detected_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM detected_documents WHERE id = ?", (detected_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def staged_documents(conn: sqlite3.Connection, batch_id: int) -> list[sqlite3.Row]:
|
||||
return list(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT * FROM detected_documents
|
||||
WHERE batch_id = ? AND status = 'staged'
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(batch_id,),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def batch_summary(conn: sqlite3.Connection, batch_id: int) -> dict[str, Any]:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n,
|
||||
COALESCE(SUM(total_paid), 0) AS total,
|
||||
SUM(CASE WHEN legible = 0 THEN 1 ELSE 0 END) AS ilegiveis,
|
||||
SUM(CASE WHEN supplier_name IS NULL OR TRIM(supplier_name) = ''
|
||||
OR total_paid IS NULL OR mes IS NULL OR ano IS NULL THEN 1 ELSE 0 END) AS incompletos
|
||||
FROM detected_documents
|
||||
WHERE batch_id = ? AND status = 'staged'
|
||||
""",
|
||||
(batch_id,),
|
||||
).fetchone()
|
||||
# "pendentes" = tudo que impede importar (ilegível OU faltando fornecedor/valor/competência).
|
||||
ilegiveis = int(row["ilegiveis"] or 0)
|
||||
incompletos = int(row["incompletos"] or 0)
|
||||
pendentes = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM detected_documents
|
||||
WHERE batch_id = ? AND status = 'staged'
|
||||
AND (legible = 0 OR supplier_name IS NULL OR TRIM(supplier_name) = '' OR total_paid IS NULL
|
||||
OR mes IS NULL OR ano IS NULL)
|
||||
""",
|
||||
(batch_id,),
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"count": int(row["n"]),
|
||||
"total": float(row["total"]),
|
||||
"ilegiveis": ilegiveis,
|
||||
"incompletos": incompletos,
|
||||
"pendentes": int(pendentes),
|
||||
}
|
||||
|
||||
|
||||
def update_staged(
|
||||
conn: sqlite3.Connection,
|
||||
detected_id: int,
|
||||
*,
|
||||
mes: int | None,
|
||||
ano: int | None,
|
||||
supplier_name: str,
|
||||
total_paid: float,
|
||||
legible: bool = True,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE detected_documents
|
||||
SET mes = ?, ano = ?, supplier_name = ?, total_paid = ?,
|
||||
legible = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = 'staged'
|
||||
""",
|
||||
(mes, ano, supplier_name, total_paid, 1 if legible else 0, detected_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def discard_staged(conn: sqlite3.Connection, detected_id: int) -> None:
|
||||
conn.execute(
|
||||
"UPDATE detected_documents SET status = 'discarded', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(detected_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def confirm_batch(conn: sqlite3.Connection, batch_id: int) -> int:
|
||||
"""Promove todos os detectados 'staged' do lote para fiscal_documents."""
|
||||
# Import local para evitar ciclo: categorization.py importa este módulo no topo.
|
||||
from .categorization import categorize_supplier
|
||||
|
||||
rows = staged_documents(conn, batch_id)
|
||||
inserted = 0
|
||||
for row in rows:
|
||||
mes, ano = row["mes"], row["ano"]
|
||||
if (
|
||||
mes is None or ano is None or not (1 <= mes <= 12)
|
||||
or not row["supplier_name"] or row["total_paid"] is None
|
||||
):
|
||||
# Sem os campos essenciais (competência, fornecedor, valor) não confirma — permanece staged.
|
||||
continue
|
||||
categoria_id = categorize_supplier(row["supplier_name"], conn, batch_id)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO fiscal_documents (
|
||||
detected_document_id, source_file_name, source_location,
|
||||
mes, ano, supplier_name, total_paid, confidence, categoria_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row["id"],
|
||||
row["source_file_name"],
|
||||
row["source_location"],
|
||||
mes,
|
||||
ano,
|
||||
row["supplier_name"],
|
||||
float(row["total_paid"]),
|
||||
row["confidence"],
|
||||
categoria_id,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE detected_documents SET status = 'confirmed', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(row["id"],),
|
||||
)
|
||||
inserted += 1
|
||||
set_batch_status(conn, batch_id, "confirmed")
|
||||
conn.commit()
|
||||
return inserted
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CRUD de fiscal_documents
|
||||
# --------------------------------------------------------------------------- #
|
||||
def create_fiscal(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
mes: int,
|
||||
ano: int,
|
||||
supplier_name: str,
|
||||
total_paid: float,
|
||||
source_file_name: str = "lançamento manual",
|
||||
source_location: str = "manual",
|
||||
confidence: str = "high",
|
||||
detected_document_id: int | None = None,
|
||||
categoria_id: int | None = None,
|
||||
) -> int:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO fiscal_documents (
|
||||
detected_document_id, source_file_name, source_location,
|
||||
mes, ano, supplier_name, total_paid, confidence, categoria_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
detected_document_id,
|
||||
source_file_name,
|
||||
source_location,
|
||||
mes,
|
||||
ano,
|
||||
supplier_name,
|
||||
total_paid,
|
||||
confidence,
|
||||
categoria_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_fiscal(conn: sqlite3.Connection, doc_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM fiscal_documents WHERE id = ?", (doc_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def update_fiscal(
|
||||
conn: sqlite3.Connection,
|
||||
doc_id: int,
|
||||
*,
|
||||
mes: int,
|
||||
ano: int,
|
||||
supplier_name: str,
|
||||
total_paid: float,
|
||||
categoria_id: int | None = None,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE fiscal_documents
|
||||
SET mes = ?, ano = ?, supplier_name = ?, total_paid = ?, categoria_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(mes, ano, supplier_name, total_paid, categoria_id, doc_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_fiscal(conn: sqlite3.Connection, doc_id: int) -> None:
|
||||
conn.execute("DELETE FROM fiscal_documents WHERE id = ?", (doc_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _category_clause(category: str | None) -> tuple[str | None, Any]:
|
||||
"""Traduz o filtro de categoria (id, 'none' para Sem categoria, ou None/''
|
||||
para nenhum filtro) numa clausula SQL + parâmetro."""
|
||||
if not category:
|
||||
return None, None
|
||||
if category == "none":
|
||||
return "categoria_id IS NULL", None
|
||||
try:
|
||||
return "categoria_id = ?", int(category)
|
||||
except ValueError:
|
||||
return None, None
|
||||
|
||||
|
||||
def _fiscal_where(
|
||||
*,
|
||||
start: tuple[int, int] | None = None,
|
||||
end: tuple[int, int] | None = None,
|
||||
supplier: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> tuple[str, list[Any]]:
|
||||
"""`start`/`end` são tuplas `(mes, ano)` delimitando o intervalo de
|
||||
competência (inclusive), comparadas via a chave `ano * 12 + mes`."""
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if start:
|
||||
start_mes, start_ano = start
|
||||
clauses.append("(ano * 12 + mes) >= ?")
|
||||
params.append(start_ano * 12 + start_mes)
|
||||
if end:
|
||||
end_mes, end_ano = end
|
||||
clauses.append("(ano * 12 + mes) <= ?")
|
||||
params.append(end_ano * 12 + end_mes)
|
||||
if supplier:
|
||||
clauses.append("supplier_name LIKE ?")
|
||||
params.append(f"%{supplier}%")
|
||||
cat_clause, cat_param = _category_clause(category)
|
||||
if cat_clause:
|
||||
clauses.append(cat_clause)
|
||||
if cat_param is not None:
|
||||
params.append(cat_param)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
return where, params
|
||||
|
||||
|
||||
FISCAL_SORT_COLUMNS = {
|
||||
"competencia": "f.ano, f.mes",
|
||||
"supplier_name": "f.supplier_name COLLATE NOCASE",
|
||||
"categoria": "categoria_nome COLLATE NOCASE",
|
||||
"total_paid": "f.total_paid",
|
||||
}
|
||||
|
||||
|
||||
def list_fiscal(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
start: tuple[int, int] | None = None,
|
||||
end: tuple[int, int] | None = None,
|
||||
supplier: str | None = None,
|
||||
category: str | None = None,
|
||||
sort: str | None = None,
|
||||
order: str = "desc",
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[sqlite3.Row]:
|
||||
where, params = _fiscal_where(start=start, end=end, supplier=supplier, category=category)
|
||||
limit_sql = ""
|
||||
if limit is not None:
|
||||
limit_sql = "LIMIT ? OFFSET ?"
|
||||
params = [*params, limit, offset]
|
||||
sort_col = FISCAL_SORT_COLUMNS.get(sort or "competencia", FISCAL_SORT_COLUMNS["competencia"])
|
||||
direction = "ASC" if (order or "").lower() == "asc" else "DESC"
|
||||
order_sql = ", ".join(f"{col} {direction}" for col in sort_col.split(", ")) + f", f.id {direction}"
|
||||
if sort_col != FISCAL_SORT_COLUMNS["competencia"]:
|
||||
order_sql += ", f.ano DESC, f.mes DESC"
|
||||
return list(
|
||||
conn.execute(
|
||||
f"""
|
||||
SELECT f.*, c.categoria AS categoria_nome
|
||||
FROM fiscal_documents f
|
||||
LEFT JOIN categoria c ON c.id = f.categoria_id
|
||||
{where}
|
||||
ORDER BY {order_sql}
|
||||
{limit_sql}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def fiscal_summary(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
start: tuple[int, int] | None = None,
|
||||
end: tuple[int, int] | None = None,
|
||||
supplier: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> tuple[int, float]:
|
||||
"""Retorna (quantidade, soma de total_paid) para o filtro informado, sem paginação."""
|
||||
where, params = _fiscal_where(start=start, end=end, supplier=supplier, category=category)
|
||||
row = conn.execute(
|
||||
f"SELECT COUNT(*) AS n, COALESCE(SUM(total_paid), 0) AS total FROM fiscal_documents {where}",
|
||||
params,
|
||||
).fetchone()
|
||||
return row["n"], row["total"]
|
||||
|
||||
|
||||
def monthly_totals(conn: sqlite3.Connection, category: str | None = None) -> list[sqlite3.Row]:
|
||||
cat_clause, cat_param = _category_clause(category)
|
||||
where = f"WHERE {cat_clause}" if cat_clause else ""
|
||||
params = [cat_param] if cat_param is not None else []
|
||||
return list(
|
||||
conn.execute(
|
||||
f"""
|
||||
SELECT ano, mes, SUM(total_paid) AS total, COUNT(*) AS count
|
||||
FROM fiscal_documents
|
||||
{where}
|
||||
GROUP BY ano, mes
|
||||
ORDER BY ano ASC, mes ASC
|
||||
""",
|
||||
params,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def supplier_totals(conn: sqlite3.Connection, limit: int = 10, category: str | None = None) -> list[sqlite3.Row]:
|
||||
cat_clause, cat_param = _category_clause(category)
|
||||
where = f"WHERE {cat_clause}" if cat_clause else ""
|
||||
params: list[Any] = [cat_param] if cat_param is not None else []
|
||||
params.append(limit)
|
||||
return list(
|
||||
conn.execute(
|
||||
f"""
|
||||
SELECT supplier_name, SUM(total_paid) AS total, COUNT(*) AS count
|
||||
FROM fiscal_documents
|
||||
{where}
|
||||
GROUP BY supplier_name
|
||||
ORDER BY total DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
params,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def category_totals(
|
||||
conn: sqlite3.Connection,
|
||||
start: tuple[int, int] | None = None,
|
||||
end: tuple[int, int] | None = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
"""Totais agrupados por categoria (LEFT JOIN, inclui 'Não Encontrado'), mais
|
||||
um grupo 'Sem categoria' à parte para categoria_id IS NULL (legado).
|
||||
|
||||
`start`/`end` são tuplas `(mes, ano)` delimitando o intervalo de competência.
|
||||
"""
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if start:
|
||||
start_mes, start_ano = start
|
||||
clauses.append("(f.ano * 12 + f.mes) >= ?")
|
||||
params.append(start_ano * 12 + start_mes)
|
||||
if end:
|
||||
end_mes, end_ano = end
|
||||
clauses.append("(f.ano * 12 + f.mes) <= ?")
|
||||
params.append(end_ano * 12 + end_mes)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
return list(
|
||||
conn.execute(
|
||||
f"""
|
||||
SELECT COALESCE(c.categoria, 'Sem categoria') AS categoria,
|
||||
f.categoria_id AS categoria_id,
|
||||
SUM(f.total_paid) AS total, COUNT(*) AS count
|
||||
FROM fiscal_documents f
|
||||
LEFT JOIN categoria c ON c.id = f.categoria_id
|
||||
{where}
|
||||
GROUP BY COALESCE(c.categoria, 'Sem categoria'), f.categoria_id
|
||||
ORDER BY total DESC
|
||||
""",
|
||||
params,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def overall_totals(conn: sqlite3.Connection, category: str | None = None) -> dict[str, Any]:
|
||||
cat_clause, cat_param = _category_clause(category)
|
||||
where = f"WHERE {cat_clause}" if cat_clause else ""
|
||||
params = [cat_param] if cat_param is not None else []
|
||||
row = conn.execute(
|
||||
f"SELECT COUNT(*) AS n, COALESCE(SUM(total_paid), 0) AS total FROM fiscal_documents {where}",
|
||||
params,
|
||||
).fetchone()
|
||||
n = int(row["n"])
|
||||
total = float(row["total"])
|
||||
return {"count": n, "total": total, "avg": (total / n) if n else 0.0}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CRUD de categoria
|
||||
# --------------------------------------------------------------------------- #
|
||||
RESERVED_CATEGORIA_ID = 1
|
||||
|
||||
|
||||
def create_categoria(conn: sqlite3.Connection, categoria: str, palavra_chave: str) -> int:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO categoria (categoria, palavra_chave) VALUES (?, ?)",
|
||||
(categoria, palavra_chave),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_categoria(conn: sqlite3.Connection, categoria_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM categoria WHERE id = ?", (categoria_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def list_categorias(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return list(conn.execute("SELECT * FROM categoria ORDER BY id ASC"))
|
||||
|
||||
|
||||
def list_categorias_por_nome(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return list(conn.execute("SELECT * FROM categoria ORDER BY categoria ASC"))
|
||||
|
||||
|
||||
def update_categoria(conn: sqlite3.Connection, categoria_id: int, categoria: str, palavra_chave: str) -> None:
|
||||
conn.execute(
|
||||
"UPDATE categoria SET categoria = ?, palavra_chave = ? WHERE id = ?",
|
||||
(categoria, palavra_chave, categoria_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_categoria(conn: sqlite3.Connection, categoria_id: int) -> bool:
|
||||
"""Exclui uma categoria, reatribuindo documentos referenciados para a
|
||||
categoria reservada (id=1). Rejeita a exclusão da própria linha reservada."""
|
||||
if categoria_id == RESERVED_CATEGORIA_ID:
|
||||
return False
|
||||
conn.execute(
|
||||
"UPDATE fiscal_documents SET categoria_id = ? WHERE categoria_id = ?",
|
||||
(RESERVED_CATEGORIA_ID, categoria_id),
|
||||
)
|
||||
conn.execute("DELETE FROM categoria WHERE id = ?", (categoria_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
|
||||
def find_categoria_by_keyword(conn: sqlite3.Connection, supplier_name: str) -> sqlite3.Row | None:
|
||||
"""Primeira categoria (id ASC, excluindo a reservada id=1) cuja palavra_chave
|
||||
é substring case-insensitive de supplier_name."""
|
||||
supplier = (supplier_name or "").upper()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM categoria WHERE id != ? ORDER BY id ASC",
|
||||
(RESERVED_CATEGORIA_ID,),
|
||||
)
|
||||
for row in rows:
|
||||
keyword = (row["palavra_chave"] or "").strip()
|
||||
if keyword and keyword.upper() in supplier:
|
||||
return row
|
||||
return None
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Resolução de competência (mês/ano) com fallback único e compartilhado.
|
||||
|
||||
Requisito: quando a competência do documento está ilegível/ausente, atribuir o
|
||||
**mês/ano corrente** (o mês da importação/lançamento) — mesma semântica que o
|
||||
antigo "primeiro dia do mês corrente", um passo mais simples (não precisa mais
|
||||
inventar um dia fictício). Tanto a extração por IA/OCR quanto o cadastro
|
||||
manual passam por aqui, para nunca divergirem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
|
||||
_ISO_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
|
||||
_BR_RE = re.compile(r"^([0-3]?\d)[/.\-]([01]?\d)[/.\-]((?:19|20)?\d{2})$")
|
||||
|
||||
MESES = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"]
|
||||
|
||||
# Intervalo de anos oferecido nos seletores de competência da UI.
|
||||
ANOS_COMPETENCIA = list(range(2026, 2031))
|
||||
|
||||
|
||||
def current_competencia(reference: date | None = None) -> tuple[int, int]:
|
||||
ref = reference or date.today()
|
||||
return ref.month, ref.year
|
||||
|
||||
|
||||
def _valid_date(year: int, month: int, day: int) -> tuple[int, int] | None:
|
||||
try:
|
||||
parsed = date(year, month, day)
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed.month, parsed.year
|
||||
|
||||
|
||||
def parse_competencia(value: str | None) -> tuple[int, int] | None:
|
||||
"""Tenta interpretar uma data em ISO (YYYY-MM-DD) ou BR (dd/mm/aaaa) e
|
||||
devolve apenas o (mês, ano) correspondente, descartando o dia.
|
||||
|
||||
Retorna None (sem fallback) se o valor não for uma data válida — usado
|
||||
quando a ausência de competência deve ficar visível (ex. staging).
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
m = _ISO_RE.match(text)
|
||||
if m:
|
||||
return _valid_date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||
m = _BR_RE.match(text)
|
||||
if m:
|
||||
day, month, year = m.groups()
|
||||
year_i = int(year)
|
||||
if year_i < 100:
|
||||
year_i += 2000
|
||||
return _valid_date(year_i, int(month), int(day))
|
||||
return None
|
||||
|
||||
|
||||
def resolve_competencia(value: str | None, *, reference: date | None = None) -> tuple[int, int]:
|
||||
"""(mês, ano) válidos a partir de `value`, ou o mês/ano corrente quando
|
||||
ilegível/ausente."""
|
||||
parsed = parse_competencia(value)
|
||||
if parsed:
|
||||
return parsed
|
||||
return current_competencia(reference)
|
||||
|
||||
|
||||
def format_competencia(mes: int | None, ano: int | None) -> str:
|
||||
"""Formata mês/ano para exibição, ex. `(6, 2026)` -> "Jun/26"."""
|
||||
if not mes or not ano or not (1 <= mes <= 12):
|
||||
return ""
|
||||
return f"{MESES[mes - 1]}/{ano % 100:02d}"
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Orquestra a extração de um arquivo enviado: OpenAI Vision -> fallback local.
|
||||
|
||||
Reutiliza `lernotafiscal.extraction` (normalização de PDF/imagem, render de
|
||||
páginas via PyMuPDF, OCR Tesseract e a detecção heurística) como caminho de
|
||||
contingência quando a IA está indisponível ou falha.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from lernotafiscal.extraction import (
|
||||
DetectedDocumentCandidate,
|
||||
apply_ocr_fallback,
|
||||
detect_documents,
|
||||
normalize_file,
|
||||
)
|
||||
|
||||
from .ai_extraction import RawExtraction, extract_with_ai
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
def _candidate_to_raw(candidate: DetectedDocumentCandidate) -> RawExtraction:
|
||||
fields = candidate.field_confidence or {}
|
||||
uncertain = [name for name, level in fields.items() if level == "low"]
|
||||
for name, val in (
|
||||
("fornecedor", candidate.supplier_name),
|
||||
("data_compra", candidate.purchase_date),
|
||||
("valor_pago", candidate.total_paid),
|
||||
):
|
||||
if val is None and name not in uncertain:
|
||||
uncertain.append(name)
|
||||
legible = candidate.confidence != "low" and len(uncertain) < 2
|
||||
return RawExtraction(
|
||||
supplier_name=candidate.supplier_name,
|
||||
purchase_date_raw=candidate.purchase_date,
|
||||
total_paid=candidate.total_paid,
|
||||
legible=legible,
|
||||
uncertain_fields=uncertain,
|
||||
extractor="local",
|
||||
raw_text=(candidate.raw_text or "")[:4000],
|
||||
)
|
||||
|
||||
|
||||
def extract_file(stored_path: Path, original_name: str) -> list[RawExtraction]:
|
||||
"""Retorna os documentos extraídos de um arquivo (>=0). Nunca levanta exceção
|
||||
de extração para o chamador — em último caso devolve lista vazia."""
|
||||
settings = get_settings()
|
||||
pages = normalize_file(stored_path, original_name, max_render_pages=settings.openai_max_pages)
|
||||
|
||||
# Caminho preferencial: enviar as imagens (páginas renderizadas / imagem) à IA.
|
||||
# Só aceitamos o resultado da IA quando ela retorna ao menos um documento.
|
||||
# Uma resposta vazia (nada encontrado OU shape inesperado) cai no fallback
|
||||
# local — evita "zero documentos" silencioso por uma resposta malformada.
|
||||
image_paths = [p.image_path for p in pages if p.image_path is not None]
|
||||
if image_paths:
|
||||
ai_result = extract_with_ai(image_paths)
|
||||
if ai_result:
|
||||
return ai_result
|
||||
|
||||
# Fallback: só agora rodamos o OCR (Tesseract) sobre as páginas sem texto
|
||||
# utilizável — evita o custo do OCR quando a IA já resolveu a extração.
|
||||
pages = apply_ocr_fallback(pages)
|
||||
candidates = detect_documents(pages, original_name)
|
||||
return [_candidate_to_raw(c) for c in candidates]
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
"""Ponto de entrada FastAPI do Lernotafiscal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from . import auth, database
|
||||
from .config import get_settings
|
||||
from .routes import (
|
||||
auth_routes,
|
||||
categoria_routes,
|
||||
dashboard_routes,
|
||||
documents_routes,
|
||||
files_routes,
|
||||
upload_routes,
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
logger = logging.getLogger("lernotafiscal")
|
||||
|
||||
# Caminhos liberados sem sessão (login e assets da própria UI).
|
||||
PUBLIC_PREFIXES = ("/login", "/static/", "/favicon.ico", "/healthz")
|
||||
|
||||
|
||||
class AuthGuardMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
if any(path == p or path.startswith(p) for p in PUBLIC_PREFIXES):
|
||||
return await call_next(request)
|
||||
if not request.session.get("user"):
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
with database.session() as conn:
|
||||
database.init_db(conn)
|
||||
if settings.secret_key_is_ephemeral:
|
||||
logger.warning("SECRET_KEY não definido: sessões não sobrevivem a reinícios. Defina SECRET_KEY em produção.")
|
||||
note = auth.seed_admin()
|
||||
if note:
|
||||
logger.warning(note)
|
||||
logger.info("OpenAI %s.", "habilitado (" + settings.openai_model + ")" if settings.openai_enabled else "desabilitado — usando OCR/heurística local")
|
||||
yield
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(title="Lernotafiscal", lifespan=lifespan)
|
||||
|
||||
# Ordem importa: SessionMiddleware é adicionado por último para ser o mais
|
||||
# externo e popular request.session ANTES da guarda de autenticação.
|
||||
app.add_middleware(AuthGuardMiddleware)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.secret_key,
|
||||
session_cookie=settings.session_cookie,
|
||||
https_only=settings.session_https_only,
|
||||
max_age=settings.session_max_age,
|
||||
same_site="lax",
|
||||
)
|
||||
|
||||
static_dir = Path(__file__).resolve().parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
|
||||
app.include_router(auth_routes.router)
|
||||
app.include_router(dashboard_routes.router)
|
||||
app.include_router(upload_routes.router)
|
||||
app.include_router(documents_routes.router)
|
||||
app.include_router(categoria_routes.router)
|
||||
app.include_router(files_routes.router)
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Rotas de login/logout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from .. import auth
|
||||
from ..templating import flash, render
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
def login_form(request: Request):
|
||||
if auth.current_user(request):
|
||||
return RedirectResponse("/", status_code=303)
|
||||
return render(request, "login.html", hide_nav=True)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada. Tente novamente.", "error")
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
if auth.authenticate(username.strip(), password):
|
||||
auth.login_session(request, username.strip())
|
||||
flash(request, "Bem-vindo de volta.", "success")
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
flash(request, "Usuário ou senha inválidos.", "error")
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request, csrf_token: str = Form("")):
|
||||
if auth.check_csrf(request, csrf_token):
|
||||
auth.logout_session(request)
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""CRUD administrativo da tabela `categoria` (categorias e palavras-chave)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from .. import auth
|
||||
from .. import database as db
|
||||
from ..templating import flash, render
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/categorias")
|
||||
def list_categorias(request: Request):
|
||||
with db.session() as conn:
|
||||
rows = [dict(r) for r in db.list_categorias_por_nome(conn)]
|
||||
return render(request, "categorias_list.html", rows=rows, reserved_id=db.RESERVED_CATEGORIA_ID)
|
||||
|
||||
|
||||
@router.get("/categorias/new")
|
||||
def new_form(request: Request):
|
||||
return render(request, "categoria_form.html", categoria=None, mode="new")
|
||||
|
||||
|
||||
@router.post("/categorias/new")
|
||||
def create_categoria(
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
categoria: str = Form(""),
|
||||
palavra_chave: str = Form(""),
|
||||
):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada.", "error")
|
||||
return RedirectResponse("/categorias/new", status_code=303)
|
||||
|
||||
nome = categoria.strip()
|
||||
keyword = palavra_chave.strip()
|
||||
if not nome or not keyword:
|
||||
flash(request, "Informe categoria e palavra-chave válidas.", "error")
|
||||
return RedirectResponse("/categorias/new", status_code=303)
|
||||
|
||||
with db.session() as conn:
|
||||
db.create_categoria(conn, nome, keyword)
|
||||
flash(request, "Categoria criada.", "success")
|
||||
return RedirectResponse("/categorias", status_code=303)
|
||||
|
||||
|
||||
@router.get("/categorias/{categoria_id}/edit")
|
||||
def edit_form(request: Request, categoria_id: int):
|
||||
with db.session() as conn:
|
||||
row = db.get_categoria(conn, categoria_id)
|
||||
if row is None:
|
||||
flash(request, "Categoria não encontrada.", "error")
|
||||
return RedirectResponse("/categorias", status_code=303)
|
||||
return render(request, "categoria_form.html", categoria=dict(row), mode="edit")
|
||||
|
||||
|
||||
@router.post("/categorias/{categoria_id}/edit")
|
||||
def update_categoria(
|
||||
request: Request,
|
||||
categoria_id: int,
|
||||
csrf_token: str = Form(""),
|
||||
categoria: str = Form(""),
|
||||
palavra_chave: str = Form(""),
|
||||
):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada.", "error")
|
||||
return RedirectResponse(f"/categorias/{categoria_id}/edit", status_code=303)
|
||||
|
||||
nome = categoria.strip()
|
||||
keyword = palavra_chave.strip()
|
||||
if not nome or not keyword:
|
||||
flash(request, "Informe categoria e palavra-chave válidas.", "error")
|
||||
return RedirectResponse(f"/categorias/{categoria_id}/edit", status_code=303)
|
||||
|
||||
with db.session() as conn:
|
||||
if db.get_categoria(conn, categoria_id) is None:
|
||||
flash(request, "Categoria não encontrada.", "error")
|
||||
return RedirectResponse("/categorias", status_code=303)
|
||||
db.update_categoria(conn, categoria_id, nome, keyword)
|
||||
flash(request, "Categoria atualizada.", "success")
|
||||
return RedirectResponse("/categorias", status_code=303)
|
||||
|
||||
|
||||
@router.post("/categorias/{categoria_id}/delete")
|
||||
def delete_categoria(request: Request, categoria_id: int, csrf_token: str = Form("")):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada.", "error")
|
||||
return RedirectResponse("/categorias", status_code=303)
|
||||
|
||||
with db.session() as conn:
|
||||
deleted = db.delete_categoria(conn, categoria_id)
|
||||
if deleted:
|
||||
flash(request, "Categoria excluída.", "info")
|
||||
else:
|
||||
flash(request, "A categoria \"Não Encontrado\" é reservada e não pode ser excluída.", "error")
|
||||
return RedirectResponse("/categorias", status_code=303)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Dashboard: KPIs, gastos por mês e por fornecedor, últimas notas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from .. import database as db
|
||||
from ..dates import format_competencia
|
||||
from ..storage import money
|
||||
from ..templating import render
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def dashboard(request: Request, category: str = Query("")):
|
||||
category = category or ""
|
||||
with db.session() as conn:
|
||||
categorias = [dict(r) for r in db.list_categorias(conn)]
|
||||
totals = db.overall_totals(conn, category=category or None)
|
||||
months = [dict(r) for r in db.monthly_totals(conn, category=category or None)]
|
||||
suppliers = [dict(r) for r in db.supplier_totals(conn, limit=8, category=category or None)]
|
||||
categories = [dict(r) for r in db.category_totals(conn)]
|
||||
recent = [dict(r) for r in db.list_fiscal(conn, category=category or None)][:10]
|
||||
|
||||
max_month = max((m["total"] for m in months), default=0) or 1
|
||||
for m in months:
|
||||
m["label"] = format_competencia(m["mes"], m["ano"])
|
||||
m["pct"] = round(m["total"] / max_month * 100, 1)
|
||||
max_sup = max((s["total"] for s in suppliers), default=0) or 1
|
||||
for s in suppliers:
|
||||
s["pct"] = round(s["total"] / max_sup * 100, 1)
|
||||
max_cat = max((c["total"] for c in categories), default=0) or 1
|
||||
for c in categories:
|
||||
c["pct"] = round(c["total"] / max_cat * 100, 1)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"dashboard.html",
|
||||
totals=totals,
|
||||
months=months,
|
||||
suppliers=suppliers,
|
||||
categories=categories,
|
||||
categorias=categorias,
|
||||
recent=recent,
|
||||
selected_category=category,
|
||||
money=money,
|
||||
)
|
||||
@@ -0,0 +1,263 @@
|
||||
"""CRUD de documentos fiscais confirmados."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Query, Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from .. import auth
|
||||
from .. import database as db
|
||||
from ..dates import current_competencia
|
||||
from ..storage import money
|
||||
from ..templating import flash, render
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PAGE_SIZE = 10
|
||||
|
||||
|
||||
def _page_window(page: int, total_pages: int, span: int = 2) -> list[int | None]:
|
||||
"""Monta a lista de números de página com None para reticências."""
|
||||
pages = {1, total_pages, *range(page - span, page + span + 1)}
|
||||
pages = sorted(p for p in pages if 1 <= p <= total_pages)
|
||||
windowed: list[int | None] = []
|
||||
prev = None
|
||||
for p in pages:
|
||||
if prev is not None and p - prev > 1:
|
||||
windowed.append(None)
|
||||
windowed.append(p)
|
||||
prev = p
|
||||
return windowed
|
||||
|
||||
|
||||
def _parse_money(value: str) -> float | None:
|
||||
text = (value or "").strip().replace("R$", "").replace(" ", "")
|
||||
if not text:
|
||||
return None
|
||||
if "," in text and "." in text:
|
||||
text = text.replace(".", "").replace(",", ".")
|
||||
elif "," in text:
|
||||
text = text.replace(",", ".")
|
||||
try:
|
||||
return round(float(text), 2)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_competencia_filter(mes: str, ano: str) -> tuple[int, int] | None:
|
||||
"""(mês, ano) a partir dos `<select>` de filtro "De"/"Até", ou None se o
|
||||
par não foi informado (filtro de competência é opcional na listagem)."""
|
||||
mes = (mes or "").strip()
|
||||
ano = (ano or "").strip()
|
||||
if not mes or not ano:
|
||||
return None
|
||||
try:
|
||||
mes_val, ano_val = int(mes), int(ano)
|
||||
except ValueError:
|
||||
return None
|
||||
if not (1 <= mes_val <= 12):
|
||||
return None
|
||||
return mes_val, ano_val
|
||||
|
||||
|
||||
@router.get("/documents")
|
||||
def list_documents(
|
||||
request: Request,
|
||||
start_mes: str = Query(""),
|
||||
start_ano: str = Query(""),
|
||||
end_mes: str = Query(""),
|
||||
end_ano: str = Query(""),
|
||||
supplier: str = Query(""),
|
||||
category: str = Query(""),
|
||||
sort: str = Query("competencia"),
|
||||
order: str = Query("desc"),
|
||||
page: int = Query(1, ge=1),
|
||||
):
|
||||
if sort not in db.FISCAL_SORT_COLUMNS:
|
||||
sort = "competencia"
|
||||
order = "asc" if order.lower() == "asc" else "desc"
|
||||
start = _parse_competencia_filter(start_mes, start_ano)
|
||||
end = _parse_competencia_filter(end_mes, end_ano)
|
||||
with db.session() as conn:
|
||||
filter_kwargs = dict(
|
||||
start=start,
|
||||
end=end,
|
||||
supplier=supplier or None,
|
||||
category=category or None,
|
||||
)
|
||||
total_count, total = db.fiscal_summary(conn, **filter_kwargs)
|
||||
total_pages = max(1, -(-total_count // PAGE_SIZE))
|
||||
page = min(page, total_pages)
|
||||
rows = [
|
||||
dict(r)
|
||||
for r in db.list_fiscal(
|
||||
conn,
|
||||
**filter_kwargs,
|
||||
sort=sort,
|
||||
order=order,
|
||||
limit=PAGE_SIZE,
|
||||
offset=(page - 1) * PAGE_SIZE,
|
||||
)
|
||||
]
|
||||
categorias = [dict(r) for r in db.list_categorias_por_nome(conn)]
|
||||
return render(
|
||||
request,
|
||||
"documents_list.html",
|
||||
rows=rows,
|
||||
total=total,
|
||||
total_count=total_count,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
page_numbers=_page_window(page, total_pages),
|
||||
filters={
|
||||
"start_mes": start_mes,
|
||||
"start_ano": start_ano,
|
||||
"end_mes": end_mes,
|
||||
"end_ano": end_ano,
|
||||
"supplier": supplier,
|
||||
"category": category,
|
||||
},
|
||||
sort=sort,
|
||||
order=order,
|
||||
categorias=categorias,
|
||||
money=money,
|
||||
)
|
||||
|
||||
|
||||
def _parse_mes_ano(mes: str, ano: str) -> tuple[int, int]:
|
||||
"""(mês, ano) a partir dos `<select>` do formulário, com o mesmo fallback
|
||||
"mês/ano corrente" que o antigo `<input type="date">` tinha para valor em
|
||||
branco."""
|
||||
fallback_mes, fallback_ano = current_competencia()
|
||||
try:
|
||||
mes_val = int(mes)
|
||||
except (TypeError, ValueError):
|
||||
mes_val = None
|
||||
if mes_val is None or not (1 <= mes_val <= 12):
|
||||
mes_val = fallback_mes
|
||||
try:
|
||||
ano_val = int(ano)
|
||||
except (TypeError, ValueError):
|
||||
ano_val = fallback_ano
|
||||
return mes_val, ano_val
|
||||
|
||||
|
||||
def _parse_categoria_id(value: str) -> int | None:
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/documents/new")
|
||||
def new_form(request: Request):
|
||||
with db.session() as conn:
|
||||
categorias = [dict(r) for r in db.list_categorias_por_nome(conn)]
|
||||
default_mes, default_ano = current_competencia()
|
||||
return render(
|
||||
request,
|
||||
"document_form.html",
|
||||
doc=None,
|
||||
mode="new",
|
||||
categorias=categorias,
|
||||
default_mes=default_mes,
|
||||
default_ano=default_ano,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/documents/new")
|
||||
def create_document(
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
mes: str = Form(""),
|
||||
ano: str = Form(""),
|
||||
supplier_name: str = Form(""),
|
||||
total_paid: str = Form(""),
|
||||
categoria_id: str = Form(""),
|
||||
):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada.", "error")
|
||||
return RedirectResponse("/documents/new", status_code=303)
|
||||
|
||||
total = _parse_money(total_paid)
|
||||
supplier = supplier_name.strip()
|
||||
if not supplier or total is None:
|
||||
flash(request, "Informe fornecedor e valor válidos.", "error")
|
||||
return RedirectResponse("/documents/new", status_code=303)
|
||||
|
||||
mes_val, ano_val = _parse_mes_ano(mes, ano)
|
||||
with db.session() as conn:
|
||||
db.create_fiscal(
|
||||
conn,
|
||||
mes=mes_val,
|
||||
ano=ano_val,
|
||||
supplier_name=supplier,
|
||||
total_paid=total,
|
||||
categoria_id=_parse_categoria_id(categoria_id),
|
||||
)
|
||||
flash(request, "Documento lançado.", "success")
|
||||
return RedirectResponse("/documents", status_code=303)
|
||||
|
||||
|
||||
@router.get("/documents/{doc_id}/edit")
|
||||
def edit_form(request: Request, doc_id: int):
|
||||
with db.session() as conn:
|
||||
doc = db.get_fiscal(conn, doc_id)
|
||||
categorias = [dict(r) for r in db.list_categorias_por_nome(conn)]
|
||||
if doc is None:
|
||||
flash(request, "Documento não encontrado.", "error")
|
||||
return RedirectResponse("/documents", status_code=303)
|
||||
return render(request, "document_form.html", doc=dict(doc), mode="edit", categorias=categorias)
|
||||
|
||||
|
||||
@router.post("/documents/{doc_id}/edit")
|
||||
def update_document(
|
||||
request: Request,
|
||||
doc_id: int,
|
||||
csrf_token: str = Form(""),
|
||||
mes: str = Form(""),
|
||||
ano: str = Form(""),
|
||||
supplier_name: str = Form(""),
|
||||
total_paid: str = Form(""),
|
||||
categoria_id: str = Form(""),
|
||||
):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada.", "error")
|
||||
return RedirectResponse(f"/documents/{doc_id}/edit", status_code=303)
|
||||
|
||||
total = _parse_money(total_paid)
|
||||
supplier = supplier_name.strip()
|
||||
if not supplier or total is None:
|
||||
flash(request, "Informe fornecedor e valor válidos.", "error")
|
||||
return RedirectResponse(f"/documents/{doc_id}/edit", status_code=303)
|
||||
|
||||
mes_val, ano_val = _parse_mes_ano(mes, ano)
|
||||
with db.session() as conn:
|
||||
if db.get_fiscal(conn, doc_id) is None:
|
||||
flash(request, "Documento não encontrado.", "error")
|
||||
return RedirectResponse("/documents", status_code=303)
|
||||
db.update_fiscal(
|
||||
conn,
|
||||
doc_id,
|
||||
mes=mes_val,
|
||||
ano=ano_val,
|
||||
supplier_name=supplier,
|
||||
total_paid=total,
|
||||
categoria_id=_parse_categoria_id(categoria_id),
|
||||
)
|
||||
flash(request, "Documento atualizado.", "success")
|
||||
return RedirectResponse("/documents", status_code=303)
|
||||
|
||||
|
||||
@router.post("/documents/{doc_id}/delete")
|
||||
def delete_document(request: Request, doc_id: int, csrf_token: str = Form("")):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada.", "error")
|
||||
return RedirectResponse("/documents", status_code=303)
|
||||
with db.session() as conn:
|
||||
db.delete_fiscal(conn, doc_id)
|
||||
flash(request, "Documento excluído.", "info")
|
||||
return RedirectResponse("/documents", status_code=303)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Servir arquivos enviados SOMENTE via rota autenticada.
|
||||
|
||||
Documentos fiscais contêm CPF/CNPJ, endereços e valores — nunca ficam num
|
||||
static mount público. O acesso passa pela guarda de sessão do middleware.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from starlette.responses import FileResponse, Response
|
||||
|
||||
from .. import database as db
|
||||
from ..config import get_settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/files/{upload_id}")
|
||||
def serve_file(request: Request, upload_id: int):
|
||||
settings = get_settings()
|
||||
with db.session() as conn:
|
||||
row = db.get_upload(conn, upload_id)
|
||||
if row is None:
|
||||
return Response("Arquivo não encontrado.", status_code=404)
|
||||
|
||||
stored = Path(row["stored_path"]).resolve()
|
||||
upload_root = settings.upload_dir.resolve()
|
||||
# trava contra path traversal: o arquivo precisa estar dentro de data/uploads
|
||||
if upload_root not in stored.parents or not stored.is_file():
|
||||
return Response("Arquivo indisponível.", status_code=404)
|
||||
|
||||
return FileResponse(
|
||||
str(stored),
|
||||
media_type=row["content_type"] or "application/octet-stream",
|
||||
filename=row["original_name"],
|
||||
content_disposition_type="inline",
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Fluxo de importação: upload -> staging (revisão) -> confirmação em lote."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from .. import auth
|
||||
from .. import database as db
|
||||
from ..config import get_settings
|
||||
from ..dates import parse_competencia
|
||||
from ..ingestion import extract_file
|
||||
from ..storage import money, safe_original_name, unique_storage_name
|
||||
from ..templating import flash, render
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _confidence(legible: bool, uncertain: list[str]) -> str:
|
||||
if legible and not uncertain:
|
||||
return "high"
|
||||
if len(uncertain) >= 2 or not legible:
|
||||
return "low"
|
||||
return "medium"
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
def upload(request: Request, csrf_token: str = Form(""), files: list[UploadFile] = File(...)):
|
||||
# Rota síncrona (não `async def`) de propósito: o processamento de cada
|
||||
# arquivo (render de PDF, OCR de fallback, chamada à IA) é bloqueante e
|
||||
# pode levar vários segundos. Starlette roda rotas síncronas numa
|
||||
# threadpool, então isso libera o event loop único do processo para
|
||||
# continuar atendendo outras requisições (dashboard, login etc.)
|
||||
# enquanto este upload é processado.
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada. Tente novamente.", "error")
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
settings = get_settings()
|
||||
accepted = 0
|
||||
rejected = 0
|
||||
|
||||
with db.session() as conn:
|
||||
batch_id = db.create_batch(conn)
|
||||
for upload_file in files:
|
||||
original = safe_original_name(upload_file.filename or "upload")
|
||||
suffix = Path(original).suffix.lower()
|
||||
content = upload_file.file.read()
|
||||
if suffix not in settings.allowed_extensions or len(content) > settings.max_upload_bytes:
|
||||
rejected += 1
|
||||
continue
|
||||
|
||||
stored_name = unique_storage_name(original)
|
||||
stored_path = settings.upload_dir / stored_name
|
||||
stored_path.write_bytes(content)
|
||||
content_type = upload_file.content_type or "application/octet-stream"
|
||||
upload_id = db.insert_upload(
|
||||
conn, batch_id, original, stored_path, content_type, len(content), commit=False
|
||||
)
|
||||
|
||||
try:
|
||||
extractions = extract_file(stored_path, original)
|
||||
for raw in extractions:
|
||||
# Sem fallback aqui: se a extração não identificou uma
|
||||
# competência plausível, mes/ano ficam nulos até o usuário
|
||||
# confirmar na tela de revisão (competência é obrigatória
|
||||
# antes de importar, não pode ser preenchida silenciosamente).
|
||||
parsed = parse_competencia(raw.purchase_date_raw)
|
||||
mes, ano = parsed if parsed else (None, None)
|
||||
uncertain = list(raw.uncertain_fields)
|
||||
db.insert_detected(
|
||||
conn,
|
||||
upload_id=upload_id,
|
||||
batch_id=batch_id,
|
||||
source_file_name=original,
|
||||
source_page=None,
|
||||
source_location=original,
|
||||
raw_text=raw.raw_text,
|
||||
mes=mes,
|
||||
ano=ano,
|
||||
supplier_name=raw.supplier_name,
|
||||
total_paid=raw.total_paid,
|
||||
confidence=_confidence(raw.legible, uncertain),
|
||||
field_confidence={},
|
||||
legible=raw.legible,
|
||||
uncertain_fields=uncertain,
|
||||
extractor=raw.extractor,
|
||||
commit=False,
|
||||
)
|
||||
accepted += 1
|
||||
status = "processed" if extractions else "needs_attention"
|
||||
message = None if extractions else "Nenhum documento fiscal detectado."
|
||||
db.update_upload_status(conn, upload_id, status, len(extractions), message, commit=False)
|
||||
except Exception as exc: # nunca deixa um arquivo derrubar o lote
|
||||
db.update_upload_status(conn, upload_id, "failed", 0, str(exc)[:300], commit=False)
|
||||
conn.commit()
|
||||
|
||||
if rejected:
|
||||
flash(request, f"{rejected} arquivo(s) recusado(s). Use PDF/JPG/PNG até {settings.max_upload_bytes // (1024*1024)} MB.", "error")
|
||||
if accepted == 0:
|
||||
flash(request, "Nenhum documento foi extraído dos arquivos enviados.", "error")
|
||||
return RedirectResponse("/", status_code=303)
|
||||
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
|
||||
|
||||
|
||||
@router.get("/import/{batch_id}/review")
|
||||
def review(request: Request, batch_id: int):
|
||||
with db.session() as conn:
|
||||
batch = db.get_batch(conn, batch_id)
|
||||
if batch is None:
|
||||
flash(request, "Lote de importação não encontrado.", "error")
|
||||
return RedirectResponse("/", status_code=303)
|
||||
rows = [dict(r) for r in db.staged_documents(conn, batch_id)]
|
||||
summary = db.batch_summary(conn, batch_id)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"staging.html",
|
||||
batch=dict(batch),
|
||||
rows=rows,
|
||||
summary=summary,
|
||||
money=money,
|
||||
)
|
||||
|
||||
|
||||
def _parse_form_int(value: str, *, min_value: int | None = None, max_value: int | None = None) -> int | None:
|
||||
value = (value or "").strip()
|
||||
if not value.isdigit():
|
||||
return None
|
||||
parsed = int(value)
|
||||
if min_value is not None and parsed < min_value:
|
||||
return None
|
||||
if max_value is not None and parsed > max_value:
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
@router.post("/import/{batch_id}/update/{detected_id}")
|
||||
def update_row(
|
||||
request: Request,
|
||||
batch_id: int,
|
||||
detected_id: int,
|
||||
csrf_token: str = Form(""),
|
||||
mes: str = Form(""),
|
||||
ano: str = Form(""),
|
||||
supplier_name: str = Form(""),
|
||||
total_paid: str = Form(""),
|
||||
):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada.", "error")
|
||||
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
|
||||
|
||||
try:
|
||||
total = float(total_paid.replace(".", "").replace(",", ".")) if "," in total_paid else float(total_paid or 0)
|
||||
except ValueError:
|
||||
flash(request, "Valor inválido na correção.", "error")
|
||||
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
|
||||
|
||||
# Sem fallback automático: mes/ano só ficam preenchidos se o usuário
|
||||
# realmente selecionou um valor nos <select> — competência ausente
|
||||
# continua bloqueando a confirmação do lote (ver `batch_summary`).
|
||||
mes_val = _parse_form_int(mes, min_value=1, max_value=12)
|
||||
ano_val = _parse_form_int(ano, min_value=1900)
|
||||
with db.session() as conn:
|
||||
db.update_staged(
|
||||
conn,
|
||||
detected_id,
|
||||
mes=mes_val,
|
||||
ano=ano_val,
|
||||
supplier_name=supplier_name.strip(),
|
||||
total_paid=round(total, 2),
|
||||
legible=True,
|
||||
)
|
||||
flash(request, "Documento corrigido.", "success")
|
||||
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
|
||||
|
||||
|
||||
@router.post("/import/{batch_id}/discard/{detected_id}")
|
||||
def discard_row(request: Request, batch_id: int, detected_id: int, csrf_token: str = Form("")):
|
||||
if auth.check_csrf(request, csrf_token):
|
||||
with db.session() as conn:
|
||||
db.discard_staged(conn, detected_id)
|
||||
flash(request, "Documento descartado do lote.", "info")
|
||||
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
|
||||
|
||||
|
||||
@router.post("/import/{batch_id}/confirm")
|
||||
def confirm(request: Request, batch_id: int, csrf_token: str = Form("")):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada.", "error")
|
||||
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
|
||||
|
||||
with db.session() as conn:
|
||||
summary = db.batch_summary(conn, batch_id)
|
||||
if summary["pendentes"] > 0:
|
||||
flash(request, f"Ainda há {summary['pendentes']} documento(s) com problema (ilegível ou sem fornecedor/valor). Corrija ou descarte antes de importar.", "error")
|
||||
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
|
||||
inserted = db.confirm_batch(conn, batch_id)
|
||||
|
||||
flash(request, f"{inserted} documento(s) importado(s) com sucesso.", "success")
|
||||
return RedirectResponse("/documents", status_code=303)
|
||||
@@ -0,0 +1,59 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// ---- Alternância de tema (persistida em localStorage) ----
|
||||
function currentTheme() {
|
||||
var explicit = document.documentElement.getAttribute("data-theme");
|
||||
if (explicit) return explicit;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
var toggle = document.getElementById("themeToggle");
|
||||
if (toggle) {
|
||||
toggle.addEventListener("click", function () {
|
||||
var next = currentTheme() === "dark" ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
try { localStorage.setItem("theme", next); } catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Área de upload (clique + arrastar/soltar + nome dos arquivos) ----
|
||||
var drop = document.getElementById("filedrop");
|
||||
var input = document.getElementById("fileInput");
|
||||
var label = document.getElementById("filedropText");
|
||||
var form = document.getElementById("uploadForm");
|
||||
var btn = document.getElementById("uploadBtn");
|
||||
|
||||
function describe(files) {
|
||||
if (!files || !files.length) return "Clique ou arraste arquivos aqui";
|
||||
if (files.length === 1) return files[0].name;
|
||||
return files.length + " arquivos selecionados";
|
||||
}
|
||||
|
||||
if (input && label) {
|
||||
input.addEventListener("change", function () {
|
||||
label.textContent = describe(input.files);
|
||||
});
|
||||
}
|
||||
if (drop && input) {
|
||||
["dragenter", "dragover"].forEach(function (ev) {
|
||||
drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.add("drag"); });
|
||||
});
|
||||
["dragleave", "drop"].forEach(function (ev) {
|
||||
drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.remove("drag"); });
|
||||
});
|
||||
drop.addEventListener("drop", function (e) {
|
||||
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
|
||||
input.files = e.dataTransfer.files;
|
||||
if (label) label.textContent = describe(input.files);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (form && btn) {
|
||||
form.addEventListener("submit", function () {
|
||||
if (input && input.files && input.files.length) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Processando…";
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,223 @@
|
||||
:root {
|
||||
--bg: #f3f5f4;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f7faf8;
|
||||
--border: #e1e7e3;
|
||||
--text: #16211c;
|
||||
--muted: #5c6b63;
|
||||
--faint: #8a978f;
|
||||
--accent: #0f7a5a;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-2: #2f6f8f;
|
||||
--danger: #b23b3b;
|
||||
--warn-bg: #fff4e0;
|
||||
--warn-border: #e6bd76;
|
||||
--warn-ink: #7a4d10;
|
||||
--ok-bg: #e2f3ea;
|
||||
--ok-ink: #14603f;
|
||||
--err-bg: #fbe4e4;
|
||||
--err-ink: #8f2626;
|
||||
--stripe: #e7ece8;
|
||||
--stripe-hover: #d8e0da;
|
||||
--shadow: 0 1px 2px rgba(16,33,26,.05), 0 10px 26px -18px rgba(16,33,26,.22);
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0e1512; --surface: #141d19; --surface-2: #18231e; --border: #26332c;
|
||||
--text: #e8efeb; --muted: #9db0a6; --faint: #6c7d74; --accent: #35c491;
|
||||
--accent-ink: #06231a; --accent-2: #62b4d6; --danger: #e06a6a;
|
||||
--warn-bg: #33270f; --warn-border: #6b5220; --warn-ink: #f0cd8a;
|
||||
--ok-bg: #123528; --ok-ink: #7fe0b4; --err-bg: #3a1d1d; --err-ink: #f0a8a8;
|
||||
--stripe: #24322b; --stripe-hover: #324338;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 14px 30px -20px rgba(0,0,0,.7);
|
||||
}
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f3f5f4; --surface: #ffffff; --surface-2: #f7faf8; --border: #e1e7e3;
|
||||
--text: #16211c; --muted: #5c6b63; --faint: #8a978f; --accent: #0f7a5a;
|
||||
--accent-ink: #ffffff; --accent-2: #2f6f8f; --danger: #b23b3b;
|
||||
--warn-bg: #fff4e0; --warn-border: #e6bd76; --warn-ink: #7a4d10;
|
||||
--ok-bg: #e2f3ea; --ok-ink: #14603f; --err-bg: #fbe4e4; --err-ink: #8f2626;
|
||||
--stripe: #e7ece8; --stripe-hover: #d8e0da;
|
||||
--shadow: 0 1px 2px rgba(16,33,26,.05), 0 10px 26px -18px rgba(16,33,26,.22);
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0e1512; --surface: #141d19; --surface-2: #18231e; --border: #26332c;
|
||||
--text: #e8efeb; --muted: #9db0a6; --faint: #6c7d74; --accent: #35c491;
|
||||
--accent-ink: #06231a; --accent-2: #62b4d6; --danger: #e06a6a;
|
||||
--warn-bg: #33270f; --warn-border: #6b5220; --warn-ink: #f0cd8a;
|
||||
--ok-bg: #123528; --ok-ink: #7fe0b4; --err-bg: #3a1d1d; --err-ink: #f0a8a8;
|
||||
--stripe: #24322b; --stripe-hover: #324338;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 14px 30px -20px rgba(0,0,0,.7);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-variant-numeric: tabular-nums;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
h1, h2, h3 { text-wrap: balance; margin: 0; }
|
||||
a { color: var(--accent); }
|
||||
.money { font-variant-numeric: tabular-nums; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: .82rem; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.r { text-align: right; }
|
||||
|
||||
/* Topbar */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
padding: 12px 22px; background: var(--surface); border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 680; text-decoration: none; color: var(--text); font-size: 1.05rem; }
|
||||
.brand-mark { font-size: 1.2rem; }
|
||||
.brand-mark.lg { font-size: 2.2rem; }
|
||||
.nav { display: flex; align-items: center; gap: 6px; margin-right: auto; }
|
||||
.nav a { padding: 7px 12px; border-radius: var(--radius-sm); text-decoration: none; color: var(--muted); font-weight: 550; font-size: .92rem; }
|
||||
.nav a:hover { background: var(--surface-2); color: var(--text); }
|
||||
.nav a.active { color: var(--accent); background: var(--surface-2); }
|
||||
.topbar-right { display: flex; align-items: center; gap: 10px; }
|
||||
.user { font-size: .85rem; color: var(--muted); }
|
||||
.theme-toggle { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; width: 36px; height: 36px; cursor: pointer; color: var(--text); font-size: 1rem; }
|
||||
.theme-toggle:hover { border-color: var(--accent); }
|
||||
.inline { display: inline; }
|
||||
|
||||
/* Layout */
|
||||
.main { max-width: 1080px; margin: 0 auto; padding: 26px 20px 70px; display: flex; flex-direction: column; gap: 20px; }
|
||||
.main-centered { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
|
||||
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 18px 20px; }
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.card h3 { font-size: 1rem; margin-bottom: 12px; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 18px; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 1px solid transparent; border-radius: var(--radius-sm); padding: 9px 15px; font: inherit; font-weight: 600; font-size: .9rem; cursor: pointer; text-decoration: none; }
|
||||
.btn-primary { background: var(--accent); color: var(--accent-ink); }
|
||||
.btn-primary:hover { filter: brightness(1.06); }
|
||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-ghost { background: transparent; border-color: var(--border); color: var(--text); }
|
||||
.btn-ghost:hover { background: var(--surface-2); }
|
||||
.btn-danger { background: transparent; border-color: var(--danger); color: var(--danger); }
|
||||
.btn-danger:hover { background: var(--danger); color: #fff; }
|
||||
.btn-sm { padding: 6px 11px; font-size: .84rem; }
|
||||
.btn-lg { padding: 12px 20px; font-size: 1rem; }
|
||||
.btn-block { width: 100%; }
|
||||
.link { color: var(--accent); text-decoration: none; font-weight: 550; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
.linkbtn { background: none; border: 0; padding: 0 0 0 10px; color: var(--accent); font: inherit; font-weight: 550; cursor: pointer; }
|
||||
.linkbtn.danger { color: var(--danger); }
|
||||
|
||||
/* Forms */
|
||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.field > span { font-size: .78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; }
|
||||
.field.grow { flex: 1; }
|
||||
input[type=text], input[type=password], input[type=date], input[type=file], select {
|
||||
font: inherit; color: var(--text); background: var(--surface-2);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 9px 11px; min-height: 40px; width: 100%;
|
||||
}
|
||||
input:focus-visible, button:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.form-actions, .filters-actions { display: flex; gap: 10px; align-items: center; }
|
||||
.form-card { max-width: 520px; }
|
||||
.field-group { display: flex; gap: 8px; }
|
||||
.field-group select { min-width: 90px; }
|
||||
|
||||
/* Login */
|
||||
.login-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 30px; width: 100%; max-width: 380px; }
|
||||
.login-head { text-align: center; margin-bottom: 20px; display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.login-head h1 { font-size: 1.4rem; }
|
||||
|
||||
/* Flash */
|
||||
.flash-stack { display: flex; flex-direction: column; gap: 8px; }
|
||||
.flash { padding: 11px 14px; border-radius: var(--radius-sm); font-size: .9rem; border: 1px solid transparent; }
|
||||
.flash-success { background: var(--ok-bg); color: var(--ok-ink); }
|
||||
.flash-error { background: var(--err-bg); color: var(--err-ink); }
|
||||
.flash-info { background: var(--surface-2); color: var(--muted); border-color: var(--border); }
|
||||
|
||||
/* Upload panel */
|
||||
.upload-panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px; display: grid; grid-template-columns: 1fr auto; gap: 18px; align-items: center; }
|
||||
.upload-panel h2 { font-size: 1.1rem; margin-bottom: 4px; }
|
||||
.upload-form { display: flex; gap: 12px; align-items: center; }
|
||||
.filedrop { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; border: 1.5px dashed var(--border); border-radius: var(--radius-sm); padding: 14px 22px; cursor: pointer; background: var(--surface-2); color: var(--muted); min-width: 240px; text-align: center; }
|
||||
.filedrop.drag { border-color: var(--accent); color: var(--accent); }
|
||||
.filedrop-icon { font-size: 1.2rem; }
|
||||
.filedrop-text { font-size: .85rem; }
|
||||
|
||||
/* KPIs */
|
||||
.kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; }
|
||||
.kpi-label { font-size: .7rem; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); font-weight: 600; }
|
||||
.kpi-value { display: block; font-size: 1.55rem; font-weight: 680; margin-top: 6px; letter-spacing: -.02em; }
|
||||
.kpi-value.money { color: var(--accent); }
|
||||
.kpi-foot { font-size: .78rem; color: var(--muted); }
|
||||
|
||||
/* Bar lists */
|
||||
.barlist { display: flex; flex-direction: column; }
|
||||
.barrow { display: grid; grid-template-columns: 92px 1fr auto; gap: 12px; align-items: center; padding: 7px 0; }
|
||||
.barrow + .barrow { border-top: 1px solid var(--surface-2); }
|
||||
.barlabel { font-size: .84rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bartrack { height: 10px; background: var(--surface-2); border-radius: 999px; overflow: hidden; }
|
||||
.barfill { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--accent-2), var(--accent)); }
|
||||
.barfill.alt { background: var(--accent); }
|
||||
.barval { font-size: .84rem; font-weight: 600; white-space: nowrap; }
|
||||
|
||||
/* Tables */
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
||||
.table thead th { text-align: left; font-size: .7rem; text-transform: uppercase; letter-spacing: .05em; color: var(--faint); font-weight: 600; padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
.table tbody td { padding: 11px 12px; border-bottom: 1px solid var(--surface-2); }
|
||||
.table tbody tr:nth-child(even) td { background: var(--stripe); }
|
||||
.table tbody tr:hover td { background: var(--stripe-hover); }
|
||||
.table tfoot td { padding: 11px 12px; font-weight: 650; border-top: 2px solid var(--border); }
|
||||
|
||||
/* Pagination */
|
||||
.pagination { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 14px 12px 4px; }
|
||||
.pagination-page { display: inline-flex; align-items: center; justify-content: center; min-width: 32px; height: 32px; padding: 0 8px; border-radius: var(--radius-sm, 6px); font-size: .85rem; color: var(--text); text-decoration: none; }
|
||||
.pagination-page:hover { background: var(--surface-2); }
|
||||
.pagination-page.current { background: var(--accent); color: var(--surface); font-weight: 650; }
|
||||
.pagination-ellipsis { color: var(--faint); padding: 0 4px; }
|
||||
.pagination .disabled { opacity: .45; pointer-events: none; }
|
||||
|
||||
/* Filters */
|
||||
.filters { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
|
||||
|
||||
/* Staging */
|
||||
.summary-banner { display: flex; gap: 28px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 18px 22px; }
|
||||
.summary-item { display: flex; flex-direction: column; gap: 2px; }
|
||||
.summary-label { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); font-weight: 600; }
|
||||
.summary-value { font-size: 1.5rem; font-weight: 700; }
|
||||
.summary-value.money { color: var(--accent); }
|
||||
.summary-item.warn .summary-value { color: var(--warn-ink); }
|
||||
.staging-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.staging-row { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 14px 16px; display: grid; grid-template-columns: 1fr auto; gap: 8px 14px; align-items: end; }
|
||||
.staging-row.row-warn { border-color: var(--warn-border); background: var(--warn-bg); }
|
||||
.staging-badges { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.badge { font-size: .68rem; font-weight: 600; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); background: var(--surface-2); text-transform: uppercase; letter-spacing: .03em; }
|
||||
.badge-warn { background: var(--warn-bg); color: var(--warn-ink); border-color: var(--warn-border); }
|
||||
.badge-high { color: var(--ok-ink); }
|
||||
.badge-low { color: var(--err-ink); }
|
||||
.badge-fields { text-transform: none; letter-spacing: 0; }
|
||||
.staging-form { grid-column: 1; display: flex; gap: 12px; align-items: end; flex-wrap: wrap; }
|
||||
.staging-form .field { min-width: 130px; }
|
||||
.staging-actions { display: flex; gap: 8px; align-items: center; }
|
||||
.staging-discard { grid-column: 2; }
|
||||
.staging-source { grid-column: 1 / -1; font-size: .74rem; color: var(--faint); }
|
||||
.confirm-bar { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 14px 18px; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.grid-2, .grid-3, .kpis { grid-template-columns: 1fr; }
|
||||
.upload-panel { grid-template-columns: 1fr; }
|
||||
.staging-row { grid-template-columns: 1fr; }
|
||||
.staging-discard { grid-column: 1; }
|
||||
.nav a:not(.btn) { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Helpers de armazenamento seguro dos arquivos enviados."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
def safe_original_name(name: str) -> str:
|
||||
return os.path.basename(name or "").replace("\x00", "") or "upload"
|
||||
|
||||
|
||||
def unique_storage_name(original: str) -> str:
|
||||
upload_dir = get_settings().upload_dir
|
||||
stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", Path(original).stem).strip("._") or "upload"
|
||||
suffix = Path(original).suffix.lower()
|
||||
index = 0
|
||||
while True:
|
||||
candidate = f"{stem}{'-' + str(index) if index else ''}{suffix}"
|
||||
if not (upload_dir / candidate).exists():
|
||||
return candidate
|
||||
index += 1
|
||||
|
||||
|
||||
def money(value: object) -> str:
|
||||
try:
|
||||
amount = float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
amount = 0.0
|
||||
return f"R$ {amount:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
||||
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR" data-theme="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ app_name }}{% endblock %} · {{ app_name }}</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='14' font-size='14'>🧾</text></svg>">
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<script>
|
||||
// Aplica o tema salvo antes da pintura para evitar flash.
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t) document.documentElement.setAttribute('data-theme', t);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{% if not hide_nav %}
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark">🧾</span>
|
||||
<span>{{ app_name }}</span>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="/" class="{{ 'active' if request.url.path == '/' else '' }}">Dashboard</a>
|
||||
<a href="/documents" class="{{ 'active' if request.url.path.startswith('/documents') and 'new' not in request.url.path else '' }}">Documentos</a>
|
||||
<a href="/categorias" class="{{ 'active' if request.url.path.startswith('/categorias') else '' }}">Categorias</a>
|
||||
<a href="/documents/new" class="btn btn-primary btn-sm">+ Novo</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<button type="button" class="theme-toggle" id="themeToggle" title="Alternar tema" aria-label="Alternar tema">
|
||||
<span class="theme-icon">◐</span>
|
||||
</button>
|
||||
{% if current_user %}
|
||||
<span class="user">{{ current_user }}</span>
|
||||
<form method="post" action="/logout" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Sair</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
{% endif %}
|
||||
|
||||
<main class="{{ 'main-centered' if hide_nav else 'main' }}">
|
||||
{% if flashes %}
|
||||
<div class="flash-stack">
|
||||
{% for f in flashes %}
|
||||
<div class="flash flash-{{ f.level }}">{{ f.message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ 'Editar categoria' if mode == 'edit' else 'Nova categoria' }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h2>{{ 'Editar categoria' if mode == 'edit' else 'Nova categoria' }}</h2>
|
||||
<p class="muted">{{ 'Ajuste os campos e salve.' if mode == 'edit' else 'Cadastre uma categoria e a palavra-chave usada para reconhecê-la.' }}</p>
|
||||
</div>
|
||||
<a href="/categorias" class="btn btn-ghost btn-sm">Voltar</a>
|
||||
</div>
|
||||
|
||||
<section class="card form-card">
|
||||
<form method="post" action="{{ '/categorias/' ~ categoria.id ~ '/edit' if mode == 'edit' else '/categorias/new' }}" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="field">
|
||||
<span>Categoria</span>
|
||||
<input type="text" name="categoria" value="{{ categoria.categoria if categoria else '' }}" required placeholder="Ex.: Alimentação">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Palavra-chave</span>
|
||||
<input type="text" name="palavra_chave" value="{{ categoria.palavra_chave if categoria else '' }}" required placeholder="Ex.: MERCADO">
|
||||
<small class="muted">Usada para reconhecer o fornecedor por correspondência (contém, sem diferenciar maiúsculas/minúsculas).</small>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'Salvar alterações' if mode == 'edit' else 'Criar categoria' }}</button>
|
||||
<a href="/categorias" class="btn btn-ghost">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Categorias{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h2>Categorias</h2>
|
||||
<p class="muted">{{ rows | length }} categoria(s)</p>
|
||||
</div>
|
||||
<a href="/categorias/new" class="btn btn-primary btn-sm">+ Nova</a>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
{% if rows %}
|
||||
<div class="table-scroll">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Categoria</th><th>Palavra-chave</th><th class="r">Ações</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in rows %}
|
||||
<tr>
|
||||
<td>{{ c.categoria }}</td>
|
||||
<td class="muted small">{{ c.palavra_chave }}</td>
|
||||
<td class="r nowrap">
|
||||
<a href="/categorias/{{ c.id }}/edit" class="link">Editar</a>
|
||||
{% if c.id != reserved_id %}
|
||||
<form method="post" action="/categorias/{{ c.id }}/delete" class="inline"
|
||||
onsubmit="return confirm('Excluir esta categoria?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="linkbtn danger">Excluir</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Nenhuma categoria cadastrada. <a href="/categorias/new" class="link">Criar a primeira</a>.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,132 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<section class="upload-panel">
|
||||
<div>
|
||||
<h2>Importar documentos</h2>
|
||||
<p class="muted">Envie PDFs ou imagens de notas e cupons fiscais. A leitura é feita
|
||||
{% if openai_enabled %}por IA (OpenAI){% else %}por OCR/heurística local{% endif %},
|
||||
e você confirma antes de gravar.</p>
|
||||
</div>
|
||||
<form method="post" action="/upload" enctype="multipart/form-data" class="upload-form" id="uploadForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="filedrop" id="filedrop">
|
||||
<input type="file" name="files" id="fileInput" multiple accept=".pdf,.jpg,.jpeg,.png" hidden>
|
||||
<span class="filedrop-icon">⬆</span>
|
||||
<span class="filedrop-text" id="filedropText">Clique ou arraste arquivos aqui</span>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary" id="uploadBtn">Enviar e revisar</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<form method="get" action="/" class="filters">
|
||||
<label class="field grow">
|
||||
<span>Categoria</span>
|
||||
<select name="category" onchange="this.form.submit()">
|
||||
<option value="">Todas</option>
|
||||
{% for c in categorias %}
|
||||
<option value="{{ c.id }}" {{ 'selected' if selected_category == c.id | string else '' }}>{{ c.categoria }}</option>
|
||||
{% endfor %}
|
||||
<option value="none" {{ 'selected' if selected_category == 'none' else '' }}>Sem categoria</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="filters-actions">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filtrar</button>
|
||||
<a href="/" class="btn btn-ghost btn-sm">Limpar</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="kpis">
|
||||
<div class="kpi">
|
||||
<span class="kpi-label">Total gasto</span>
|
||||
<strong class="kpi-value money">{{ totals.total | money }}</strong>
|
||||
<span class="kpi-foot">{{ totals.count }} documento(s)</span>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<span class="kpi-label">Ticket médio</span>
|
||||
<strong class="kpi-value">{{ totals.avg | money }}</strong>
|
||||
<span class="kpi-foot">por documento</span>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<span class="kpi-label">Meses com registro</span>
|
||||
<strong class="kpi-value">{{ months | length }}</strong>
|
||||
<span class="kpi-foot">{{ suppliers | length }} fornecedor(es) no top</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid-3">
|
||||
<div class="card">
|
||||
<h3>Gastos por mês</h3>
|
||||
{% if months %}
|
||||
<div class="barlist">
|
||||
{% for m in months %}
|
||||
<div class="barrow">
|
||||
<span class="barlabel">{{ m.label }}</span>
|
||||
<span class="bartrack"><span class="barfill" style="width: {{ m.pct }}%"></span></span>
|
||||
<span class="barval">{{ m.total | money }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}<p class="muted">Sem dados ainda.</p>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Gastos por fornecedor</h3>
|
||||
{% if suppliers %}
|
||||
<div class="barlist">
|
||||
{% for s in suppliers %}
|
||||
<div class="barrow">
|
||||
<span class="barlabel" title="{{ s.supplier_name }}">{{ s.supplier_name }}</span>
|
||||
<span class="bartrack"><span class="barfill alt" style="width: {{ s.pct }}%"></span></span>
|
||||
<span class="barval">{{ s.total | money }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}<p class="muted">Sem dados ainda.</p>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Gastos por categoria</h3>
|
||||
{% if categories %}
|
||||
<div class="barlist">
|
||||
{% for c in categories %}
|
||||
<div class="barrow">
|
||||
<span class="barlabel" title="{{ c.categoria }}">{{ c.categoria }}</span>
|
||||
<span class="bartrack"><span class="barfill" style="width: {{ c.pct }}%"></span></span>
|
||||
<span class="barval">{{ c.total | money }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}<p class="muted">Sem dados ainda.</p>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h3>Últimos documentos</h3>
|
||||
<a href="/documents" class="btn btn-ghost btn-sm">Ver todos</a>
|
||||
</div>
|
||||
{% if recent %}
|
||||
<div class="table-scroll">
|
||||
<table class="table">
|
||||
<thead><tr><th>Competência</th><th>Fornecedor</th><th>Categoria</th><th class="r">Valor</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in recent %}
|
||||
<tr>
|
||||
<td class="nowrap">{{ d.mes | competencia(d.ano) }}</td>
|
||||
<td>{{ d.supplier_name }}</td>
|
||||
<td>{{ d.categoria_nome or '' }}</td>
|
||||
<td class="r money nowrap">{{ d.total_paid | money }}</td>
|
||||
<td class="r"><a href="/documents/{{ d.id }}/edit" class="link">editar</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}<p class="muted">Nenhum documento confirmado. Comece importando acima.</p>{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ 'Editar documento' if mode == 'edit' else 'Novo lançamento' }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h2>{{ 'Editar documento' if mode == 'edit' else 'Novo lançamento' }}</h2>
|
||||
<p class="muted">{{ 'Ajuste os campos e salve.' if mode == 'edit' else 'Lançamento manual de uma despesa fiscal.' }}</p>
|
||||
</div>
|
||||
<a href="/documents" class="btn btn-ghost btn-sm">Voltar</a>
|
||||
</div>
|
||||
|
||||
<section class="card form-card">
|
||||
<form method="post" action="{{ '/documents/' ~ doc.id ~ '/edit' if mode == 'edit' else '/documents/new' }}" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="field">
|
||||
<span>Competência</span>
|
||||
<span class="field-group">
|
||||
<select name="mes" required>
|
||||
{% for m in meses_competencia %}
|
||||
<option value="{{ loop.index }}" {{ 'selected' if (doc.mes if doc else default_mes) == loop.index else '' }}>{{ m }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="ano" required>
|
||||
{% for a in anos_competencia %}
|
||||
<option value="{{ a }}" {{ 'selected' if (doc.ano if doc else default_ano) == a else '' }}>{{ a }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
<small class="muted">Se não selecionado, será usado o mês/ano corrente.</small>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Fornecedor</span>
|
||||
<input type="text" name="supplier_name" value="{{ doc.supplier_name if doc else '' }}" required placeholder="Nome do estabelecimento">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Valor pago (R$)</span>
|
||||
<input type="text" inputmode="decimal" name="total_paid" value="{{ '%.2f'|format(doc.total_paid) if doc else '' }}" required placeholder="0,00">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Categoria</span>
|
||||
<select name="categoria_id">
|
||||
<option value="">Sem categoria</option>
|
||||
{% for c in categorias %}
|
||||
<option value="{{ c.id }}" {{ 'selected' if doc and doc.categoria_id == c.id else '' }}>{{ c.categoria }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'Salvar alterações' if mode == 'edit' else 'Lançar documento' }}</button>
|
||||
<a href="/documents" class="btn btn-ghost">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,144 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Documentos{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h2>Documentos</h2>
|
||||
<p class="muted">{{ total_count }} registro(s) · total {{ total | money }}</p>
|
||||
</div>
|
||||
<a href="/documents/new" class="btn btn-primary btn-sm">+ Novo lançamento</a>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<form method="get" action="/documents" class="filters">
|
||||
<label class="field">
|
||||
<span>De (competência)</span>
|
||||
<span class="field-group">
|
||||
<select name="start_mes">
|
||||
<option value="" {{ 'selected' if not filters.start_mes else '' }}>Mês</option>
|
||||
{% for m in meses_competencia %}
|
||||
<option value="{{ loop.index }}" {{ 'selected' if filters.start_mes == loop.index | string else '' }}>{{ m }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="start_ano">
|
||||
<option value="" {{ 'selected' if not filters.start_ano else '' }}>Ano</option>
|
||||
{% for a in anos_competencia %}
|
||||
<option value="{{ a }}" {{ 'selected' if filters.start_ano == a | string else '' }}>{{ a }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Até (competência)</span>
|
||||
<span class="field-group">
|
||||
<select name="end_mes">
|
||||
<option value="" {{ 'selected' if not filters.end_mes else '' }}>Mês</option>
|
||||
{% for m in meses_competencia %}
|
||||
<option value="{{ loop.index }}" {{ 'selected' if filters.end_mes == loop.index | string else '' }}>{{ m }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="end_ano">
|
||||
<option value="" {{ 'selected' if not filters.end_ano else '' }}>Ano</option>
|
||||
{% for a in anos_competencia %}
|
||||
<option value="{{ a }}" {{ 'selected' if filters.end_ano == a | string else '' }}>{{ a }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
<label class="field grow"><span>Fornecedor</span><input type="text" name="supplier" value="{{ filters.supplier }}" placeholder="contém…"></label>
|
||||
<label class="field grow">
|
||||
<span>Categoria</span>
|
||||
<select name="category" onchange="this.form.submit()">
|
||||
<option value="">Todas</option>
|
||||
{% for c in categorias %}
|
||||
<option value="{{ c.id }}" {{ 'selected' if filters.category == c.id | string else '' }}>{{ c.categoria }}</option>
|
||||
{% endfor %}
|
||||
<option value="none" {{ 'selected' if filters.category == 'none' else '' }}>Sem categoria</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="filters-actions">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filtrar</button>
|
||||
<a href="/documents" class="btn btn-ghost btn-sm">Limpar</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
{% if rows %}
|
||||
{% macro sort_url(key) -%}
|
||||
/documents?start_mes={{ filters.start_mes | urlencode }}&start_ano={{ filters.start_ano | urlencode }}&end_mes={{ filters.end_mes | urlencode }}&end_ano={{ filters.end_ano | urlencode }}&supplier={{ filters.supplier | urlencode }}&category={{ filters.category | urlencode }}&sort={{ key }}&order={{ 'asc' if (sort == key and order == 'desc') else 'desc' }}
|
||||
{%- endmacro %}
|
||||
{% macro sort_arrow(key) -%}
|
||||
{% if sort == key %}{{ ' ▲' if order == 'asc' else ' ▼' }}{% endif %}
|
||||
{%- endmacro %}
|
||||
<div class="table-scroll">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><a href="{{ sort_url('competencia') }}" class="link">Competência{{ sort_arrow('competencia') }}</a></th>
|
||||
<th><a href="{{ sort_url('supplier_name') }}" class="link">Fornecedor{{ sort_arrow('supplier_name') }}</a></th>
|
||||
<th><a href="{{ sort_url('categoria') }}" class="link">Categoria{{ sort_arrow('categoria') }}</a></th>
|
||||
<th class="r"><a href="{{ sort_url('total_paid') }}" class="link">Valor{{ sort_arrow('total_paid') }}</a></th>
|
||||
<th>Origem</th>
|
||||
<th class="r">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in rows %}
|
||||
<tr>
|
||||
<td class="nowrap">{{ d.mes | competencia(d.ano) }}</td>
|
||||
<td>{{ d.supplier_name }}</td>
|
||||
<td>{{ d.categoria_nome or '' }}</td>
|
||||
<td class="r money nowrap">{{ d.total_paid | money }}</td>
|
||||
<td class="muted small">{{ d.source_location }}</td>
|
||||
<td class="r nowrap">
|
||||
<a href="/documents/{{ d.id }}/edit" class="link">Editar</a>
|
||||
<form method="post" action="/documents/{{ d.id }}/delete" class="inline"
|
||||
onsubmit="return confirm('Excluir este documento?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="linkbtn danger">Excluir</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr><td colspan="3">Total registros: {{ total_count }}</td><td class="r money">{{ total | money }}</td><td colspan="2"></td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if total_pages > 1 %}
|
||||
{% macro page_url(p) -%}
|
||||
/documents?start_mes={{ filters.start_mes | urlencode }}&start_ano={{ filters.start_ano | urlencode }}&end_mes={{ filters.end_mes | urlencode }}&end_ano={{ filters.end_ano | urlencode }}&supplier={{ filters.supplier | urlencode }}&category={{ filters.category | urlencode }}&sort={{ sort }}&order={{ order }}&page={{ p }}
|
||||
{%- endmacro %}
|
||||
<nav class="pagination" aria-label="Paginação">
|
||||
{% if page <= 1 %}
|
||||
<span class="btn btn-ghost btn-sm disabled" aria-disabled="true">« Anterior</span>
|
||||
{% else %}
|
||||
<a href="{{ page_url(page - 1) }}" class="btn btn-ghost btn-sm">« Anterior</a>
|
||||
{% endif %}
|
||||
{% for p in page_numbers %}
|
||||
{% if p is none %}
|
||||
<span class="pagination-ellipsis">…</span>
|
||||
{% elif p == page %}
|
||||
<span class="pagination-page current" aria-current="page">{{ p }}</span>
|
||||
{% else %}
|
||||
<a href="{{ page_url(p) }}" class="pagination-page">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if page >= total_pages %}
|
||||
<span class="btn btn-ghost btn-sm disabled" aria-disabled="true">Próxima »</span>
|
||||
{% else %}
|
||||
<a href="{{ page_url(page + 1) }}" class="btn btn-ghost btn-sm">Próxima »</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="muted">Nenhum documento encontrado. <a href="/documents/new" class="link">Lançar manualmente</a> ou <a href="/" class="link">importar</a>.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Entrar{% endblock %}
|
||||
{% block content %}
|
||||
<div class="login-card">
|
||||
<div class="login-head">
|
||||
<span class="brand-mark lg">🧾</span>
|
||||
<h1>{{ app_name }}</h1>
|
||||
<p class="muted">Controle de despesas fiscais</p>
|
||||
</div>
|
||||
<form method="post" action="/login" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="field">
|
||||
<span>Usuário</span>
|
||||
<input type="text" name="username" autocomplete="username" required autofocus>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Senha</span>
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary btn-block">Entrar</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,105 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Revisar importação{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h2>Revisar antes de importar</h2>
|
||||
<p class="muted">Confira os documentos extraídos. Corrija os ilegíveis e confirme.</p>
|
||||
</div>
|
||||
<a href="/" class="btn btn-ghost btn-sm">Cancelar</a>
|
||||
</div>
|
||||
|
||||
<section class="summary-banner">
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Documentos</span>
|
||||
<strong class="summary-value">{{ summary.count }}</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Valor total</span>
|
||||
<strong class="summary-value money">{{ summary.total | money }}</strong>
|
||||
</div>
|
||||
{% if summary.pendentes %}
|
||||
<div class="summary-item warn">
|
||||
<span class="summary-label">Precisam de correção</span>
|
||||
<strong class="summary-value">{{ summary.pendentes }}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if not rows %}
|
||||
<div class="card"><p class="muted">Nenhum documento neste lote. <a href="/" class="link">Voltar</a>.</p></div>
|
||||
{% else %}
|
||||
|
||||
<div class="staging-list">
|
||||
{% for r in rows %}
|
||||
<article class="staging-row {{ 'row-warn' if (not r.legible or not r.supplier_name or r.total_paid is none or not r.mes or not r.ano) else '' }}">
|
||||
<div class="staging-badges">
|
||||
{% if not r.legible %}<span class="badge badge-warn">Ilegível — revise</span>{% endif %}
|
||||
{% if r.legible and (not r.supplier_name or r.total_paid is none or not r.mes or not r.ano) %}<span class="badge badge-warn">Incompleto — preencha</span>{% endif %}
|
||||
<span class="badge badge-conf badge-{{ r.confidence }}">{{ r.confidence }}</span>
|
||||
<span class="badge badge-src">{{ 'IA' if r.extractor == 'openai' else 'OCR local' }}</span>
|
||||
{% if r.uncertain_fields and r.uncertain_fields != '[]' %}
|
||||
<span class="badge badge-fields">incertos: {{ r.uncertain_fields | replace('[','') | replace(']','') | replace('"','') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="post" action="/import/{{ batch.id }}/update/{{ r.id }}" class="staging-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="field">
|
||||
<span>Competência</span>
|
||||
<span class="field-group">
|
||||
<select name="mes" required>
|
||||
<option value="" disabled {{ 'selected' if not r.mes else '' }}>Mês</option>
|
||||
{% for m in meses_competencia %}
|
||||
<option value="{{ loop.index }}" {{ 'selected' if r.mes == loop.index else '' }}>{{ m }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="ano" required>
|
||||
<option value="" disabled {{ 'selected' if not r.ano else '' }}>Ano</option>
|
||||
{% for a in anos_competencia %}
|
||||
<option value="{{ a }}" {{ 'selected' if r.ano == a else '' }}>{{ a }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
<label class="field grow">
|
||||
<span>Fornecedor</span>
|
||||
<input type="text" name="supplier_name" value="{{ r.supplier_name or '' }}" placeholder="Nome do estabelecimento">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Valor (R$)</span>
|
||||
<input type="text" inputmode="decimal" name="total_paid" value="{{ '%.2f'|format(r.total_paid) if r.total_paid is not none else '' }}" placeholder="0,00">
|
||||
</label>
|
||||
<div class="staging-actions">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Salvar correção</button>
|
||||
<a href="/files/{{ r.upload_id }}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm">Ver arquivo</a>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="/import/{{ batch.id }}/discard/{{ r.id }}" class="staging-discard"
|
||||
onsubmit="return confirm('Descartar este documento do lote?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Descartar</button>
|
||||
</form>
|
||||
<div class="staging-source">{{ r.source_file_name }}</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="confirm-bar">
|
||||
<div class="muted">
|
||||
{% if summary.pendentes %}
|
||||
Corrija os {{ summary.pendentes }} documento(s) pendente(s) antes de importar.
|
||||
{% else %}
|
||||
Tudo pronto para importar.
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="post" action="/import/{{ batch.id }}/confirm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-primary btn-lg" {{ 'disabled' if summary.pendentes else '' }}>
|
||||
Confirmar importação de {{ summary.count }} documento(s) · {{ summary.total | money }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Configuração do Jinja2 e helpers de renderização/flash."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from . import auth
|
||||
from .config import get_settings
|
||||
from .dates import ANOS_COMPETENCIA, MESES, format_competencia
|
||||
from .storage import money
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
||||
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
templates.env.filters["money"] = money
|
||||
templates.env.filters["competencia"] = format_competencia
|
||||
|
||||
|
||||
def flash(request: Request, message: str, level: str = "info") -> None:
|
||||
bucket = request.session.setdefault("_flash", [])
|
||||
bucket.append({"message": message, "level": level})
|
||||
|
||||
|
||||
def pop_flash(request: Request) -> list[dict[str, str]]:
|
||||
return request.session.pop("_flash", [])
|
||||
|
||||
|
||||
def render(request: Request, name: str, status_code: int = 200, **context) -> HTMLResponse:
|
||||
settings = get_settings()
|
||||
base = {
|
||||
"request": request,
|
||||
"current_user": auth.current_user(request),
|
||||
"csrf_token": auth.get_csrf_token(request),
|
||||
"flashes": pop_flash(request),
|
||||
"openai_enabled": settings.openai_enabled,
|
||||
"app_name": "Lernotafiscal",
|
||||
"meses_competencia": MESES,
|
||||
"anos_competencia": ANOS_COMPETENCIA,
|
||||
}
|
||||
base.update(context)
|
||||
return templates.TemplateResponse(request, name, base, status_code=status_code)
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Lernotafiscal (FastAPI/Uvicorn)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=lernotafiscal
|
||||
Group=lernotafiscal
|
||||
WorkingDirectory=/opt/lernotafiscal
|
||||
EnvironmentFile=/opt/lernotafiscal/.env
|
||||
ExecStart=/opt/lernotafiscal/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
# Hardening básico
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/opt/lernotafiscal/data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,21 @@
|
||||
# Nginx como proxy reverso do Lernotafiscal.
|
||||
# Copie para /etc/nginx/sites-available/lernotafiscal, ajuste server_name e
|
||||
# habilite com: ln -s .../sites-available/lernotafiscal /etc/nginx/sites-enabled/
|
||||
# Depois rode `certbot --nginx -d seu.dominio.com` para gerar o bloco TLS/443.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name seu.dominio.com;
|
||||
|
||||
# Precisa acomodar MAX_UPLOAD_BYTES (12 MB por padrão) + folga do multipart.
|
||||
client_max_body_size 16m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY obrigatoria}
|
||||
SESSION_HTTPS_ONLY: ${SESSION_HTTPS_ONLY:-true}
|
||||
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:?ADMIN_USERNAME obrigatorio}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD obrigatoria}
|
||||
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o}
|
||||
OPENAI_MAX_PAGES: ${OPENAI_MAX_PAGES:-8}
|
||||
|
||||
DB_PATH: ${DB_PATH:-data/app.sqlite3}
|
||||
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-12582912}
|
||||
|
||||
HOST: ${HOST:-0.0.0.0}
|
||||
PORT: ${PORT:-8000}
|
||||
|
||||
# Se o Dockerfile já define o comando correto, esta variável pode
|
||||
# permanecer no padrão ou ser removida se não for utilizada por ele.
|
||||
APP_MODULE: ${APP_MODULE:-app.main:app}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
- lernotafiscal-data:/app/data
|
||||
|
||||
expose:
|
||||
- "8000"
|
||||
|
||||
volumes:
|
||||
lernotafiscal-data:
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
@echo off
|
||||
echo === INICIANDO UPLOAD PARA GITHUB ===
|
||||
|
||||
REM Inicializar repositório Git
|
||||
echo Inicializando repositorio Git...
|
||||
git init
|
||||
|
||||
REM Adicionar todos os arquivos
|
||||
echo Adicionando todos os arquivos...
|
||||
git add .
|
||||
|
||||
REM Fazer commit inicial
|
||||
echo Realizando commit inicial...
|
||||
git commit -m "Commit inicial - upload de todos os arquivos da pasta"
|
||||
|
||||
REM Adicionar repositório remoto
|
||||
echo Conectando ao repositorio remoto...
|
||||
REM git remote add origin https://gitea.aplicativopro.com/wander/LerNotaFiscal.git
|
||||
|
||||
git remote add origin https://gitea.aplicativopro.com/wander/LerNota.git
|
||||
|
||||
REM Definir branch principal
|
||||
echo Definindo branch principal como 'main'...
|
||||
git branch -M main
|
||||
|
||||
REM Fazer push para o GitHub
|
||||
echo Fazendo upload para o GitHub...
|
||||
git push -u origin main
|
||||
|
||||
echo === UPLOAD CONCLUIDO COM SUCESSO! ===
|
||||
|
||||
pause
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Lernotafiscal application package."""
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DATA_DIR = ROOT / "data"
|
||||
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||
PREVIEW_DIR = DATA_DIR / "previews"
|
||||
DB_PATH = DATA_DIR / "lernotafiscal.sqlite3"
|
||||
|
||||
|
||||
def ensure_storage() -> None:
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||
PREVIEW_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def connect(db_path: Path = DB_PATH) -> sqlite3.Connection:
|
||||
ensure_storage()
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db(conn: sqlite3.Connection) -> None:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS uploaded_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
original_name TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
detected_count INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS detected_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
upload_id INTEGER NOT NULL REFERENCES uploaded_files(id) ON DELETE CASCADE,
|
||||
source_file_name TEXT NOT NULL,
|
||||
source_page INTEGER,
|
||||
source_location TEXT NOT NULL,
|
||||
raw_text TEXT NOT NULL,
|
||||
purchase_date TEXT,
|
||||
supplier_name TEXT,
|
||||
total_paid REAL,
|
||||
confidence TEXT NOT NULL,
|
||||
field_confidence_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fiscal_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
detected_document_id INTEGER REFERENCES detected_documents(id) ON DELETE SET NULL,
|
||||
source_file_name TEXT NOT NULL,
|
||||
source_location TEXT NOT NULL,
|
||||
purchase_date TEXT NOT NULL,
|
||||
supplier_name TEXT NOT NULL,
|
||||
total_paid REAL NOT NULL,
|
||||
confidence TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def insert_upload(
|
||||
conn: sqlite3.Connection,
|
||||
original_name: str,
|
||||
stored_path: Path,
|
||||
content_type: str,
|
||||
size_bytes: int,
|
||||
) -> int:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO uploaded_files (original_name, stored_path, content_type, size_bytes)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(original_name, str(stored_path), content_type, size_bytes),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_upload_status(
|
||||
conn: sqlite3.Connection,
|
||||
upload_id: int,
|
||||
status: str,
|
||||
detected_count: int,
|
||||
message: str | None = None,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE uploaded_files
|
||||
SET status = ?, detected_count = ?, message = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, detected_count, message, upload_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def insert_detected_document(
|
||||
conn: sqlite3.Connection,
|
||||
upload_id: int,
|
||||
candidate: Any,
|
||||
) -> int:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO detected_documents (
|
||||
upload_id, source_file_name, source_page, source_location, raw_text,
|
||||
purchase_date, supplier_name, total_paid, confidence, field_confidence_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
upload_id,
|
||||
candidate.source_file_name,
|
||||
candidate.source_page,
|
||||
candidate.source_location,
|
||||
candidate.raw_text,
|
||||
candidate.purchase_date,
|
||||
candidate.supplier_name,
|
||||
candidate.total_paid,
|
||||
candidate.confidence,
|
||||
json.dumps(candidate.field_confidence, ensure_ascii=True),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def pending_documents(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return list(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT d.*, u.original_name
|
||||
FROM detected_documents d
|
||||
JOIN uploaded_files u ON u.id = d.upload_id
|
||||
WHERE d.status = 'pending'
|
||||
ORDER BY d.created_at DESC, d.id DESC
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def uploads_summary(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return list(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM uploaded_files
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def attention_uploads(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return list(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM uploaded_files
|
||||
WHERE status IN ('needs_attention', 'failed')
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def review_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT status, COUNT(*) AS total
|
||||
FROM detected_documents
|
||||
GROUP BY status
|
||||
"""
|
||||
).fetchall()
|
||||
counts = {"pending": 0, "confirmed": 0, "ignored": 0}
|
||||
for row in rows:
|
||||
counts[row["status"]] = int(row["total"])
|
||||
return counts
|
||||
|
||||
|
||||
def get_detected(conn: sqlite3.Connection, detected_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM detected_documents WHERE id = ?",
|
||||
(detected_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def confirm_document(
|
||||
conn: sqlite3.Connection,
|
||||
detected_id: int,
|
||||
purchase_date: str,
|
||||
supplier_name: str,
|
||||
total_paid: float,
|
||||
) -> None:
|
||||
row = get_detected(conn, detected_id)
|
||||
if row is None:
|
||||
raise ValueError("Documento detectado nao encontrado.")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE detected_documents
|
||||
SET purchase_date = ?, supplier_name = ?, total_paid = ?,
|
||||
status = 'confirmed', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(purchase_date, supplier_name, total_paid, detected_id),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO fiscal_documents (
|
||||
detected_document_id, source_file_name, source_location,
|
||||
purchase_date, supplier_name, total_paid, confidence
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
detected_id,
|
||||
row["source_file_name"],
|
||||
row["source_location"],
|
||||
purchase_date,
|
||||
supplier_name,
|
||||
total_paid,
|
||||
row["confidence"],
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def ignore_document(conn: sqlite3.Connection, detected_id: int) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE detected_documents
|
||||
SET status = 'ignored', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(detected_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def dashboard_documents(
|
||||
conn: sqlite3.Connection,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
supplier: str | None = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
clauses = []
|
||||
params: list[Any] = []
|
||||
if start:
|
||||
clauses.append("purchase_date >= ?")
|
||||
params.append(start)
|
||||
if end:
|
||||
clauses.append("purchase_date <= ?")
|
||||
params.append(end)
|
||||
if supplier:
|
||||
clauses.append("supplier_name LIKE ?")
|
||||
params.append(f"%{supplier}%")
|
||||
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
return list(
|
||||
conn.execute(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM fiscal_documents
|
||||
{where}
|
||||
ORDER BY purchase_date DESC, id DESC
|
||||
""",
|
||||
params,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def monthly_totals(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return list(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT substr(purchase_date, 1, 7) AS month, SUM(total_paid) AS total, COUNT(*) AS count
|
||||
FROM fiscal_documents
|
||||
GROUP BY substr(purchase_date, 1, 7)
|
||||
ORDER BY month DESC
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def supplier_totals(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return list(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT supplier_name, SUM(total_paid) AS total, COUNT(*) AS count
|
||||
FROM fiscal_documents
|
||||
GROUP BY supplier_name
|
||||
ORDER BY total DESC
|
||||
LIMIT 10
|
||||
"""
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,392 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .db import PREVIEW_DIR
|
||||
|
||||
|
||||
PDF_RENDER_SCALE = 3.0
|
||||
MAX_BLOCKS_PER_PAGE = 12
|
||||
DATE_RE = re.compile(r"\b([0-3]?\d)[/-]([01]?\d)[/-]((?:20)?\d{2})\b")
|
||||
MONEY_RE = re.compile(r"(?<!\d)(?:R\$\s*)?(\d{1,3}(?:\.\d{3})*,\d{2}|\d+\.\d{2})(?!\d)")
|
||||
CNPJ_RE = re.compile(r"\b\d{2}\.?\d{3}\.?\d{3}/?\d{4}-?\d{2}\b")
|
||||
ANCHOR_RE = re.compile(
|
||||
r"\b(CNPJ|CUPOM|NFC-?E|SAT|EXTRATO|DANFE|VALOR\s+TOTAL|TOTAL\s+A\s+PAGAR)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
START_ANCHOR_RE = re.compile(r"\b(CNPJ|CUPOM|NFC-?E|SAT|EXTRATO|DANFE)\b", re.IGNORECASE)
|
||||
LABEL_RE = re.compile(
|
||||
r"(valor\s+total|total\s+a\s+pagar|valor\s+pago|total\s+pago|total\s+r\$|total)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageSource:
|
||||
page_number: int
|
||||
text: str
|
||||
image_path: Path | None
|
||||
source_kind: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectedDocumentCandidate:
|
||||
source_file_name: str
|
||||
source_page: int | None
|
||||
source_location: str
|
||||
raw_text: str
|
||||
purchase_date: str | None
|
||||
supplier_name: str | None
|
||||
total_paid: float | None
|
||||
confidence: str
|
||||
field_confidence: dict[str, str]
|
||||
|
||||
|
||||
def normalize_file(path: Path, original_name: str, max_render_pages: int | None = None) -> list[PageSource]:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".pdf":
|
||||
return normalize_pdf(path, max_render_pages)
|
||||
if suffix in {".jpg", ".jpeg", ".png"}:
|
||||
return [normalize_image(path, page_number=1)]
|
||||
return [PageSource(1, "", None, "unsupported")]
|
||||
|
||||
|
||||
def normalize_pdf(path: Path, max_render_pages: int | None = None) -> list[PageSource]:
|
||||
"""Extrai texto (pypdf/naive) e renderiza páginas em imagem, sem OCR.
|
||||
|
||||
O OCR (Tesseract) é caro e só é usado no fallback local — ver
|
||||
`apply_ocr_fallback` — por isso não roda aqui incondicionalmente. O
|
||||
caminho preferencial (IA/Vision) usa só as imagens renderizadas.
|
||||
"""
|
||||
pages = extract_pdf_text_with_pypdf(path)
|
||||
if not pages:
|
||||
pages = extract_pdf_text_naive(path)
|
||||
|
||||
preview_images = render_pdf_pages_to_images(path, max_render_pages)
|
||||
page_count = max(len(pages), len(preview_images), count_pdf_pages(path), 1)
|
||||
normalized: list[PageSource] = []
|
||||
for index in range(page_count):
|
||||
text = pages[index] if index < len(pages) else ""
|
||||
normalized.append(
|
||||
PageSource(
|
||||
page_number=index + 1,
|
||||
text=text if has_usable_text(text) else "",
|
||||
image_path=preview_images[index] if index < len(preview_images) else None,
|
||||
source_kind="pdf",
|
||||
)
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_image(path: Path, page_number: int) -> PageSource:
|
||||
"""Sem OCR aqui — ver `apply_ocr_fallback`."""
|
||||
return PageSource(page_number, "", path, "image")
|
||||
|
||||
|
||||
def apply_ocr_fallback(pages: list[PageSource]) -> list[PageSource]:
|
||||
"""Roda OCR (Tesseract) nas páginas sem texto utilizável que têm imagem.
|
||||
|
||||
Só deve ser chamado quando a extração por IA não está disponível/falhou:
|
||||
o OCR é descartado sempre que a IA extrai com sucesso, então adiar essa
|
||||
chamada evita o custo (tipicamente segundos por página) no caminho
|
||||
quente onde ela nunca seria usada.
|
||||
"""
|
||||
updated: list[PageSource] = []
|
||||
for page in pages:
|
||||
text = page.text
|
||||
if not has_usable_text(text) and page.image_path is not None:
|
||||
ocr_text = extract_image_text(page.image_path)
|
||||
if has_usable_text(ocr_text):
|
||||
text = ocr_text
|
||||
updated.append(PageSource(page.page_number, text, page.image_path, page.source_kind))
|
||||
return updated
|
||||
|
||||
|
||||
def extract_pdf_text_with_pypdf(path: Path) -> list[str]:
|
||||
try:
|
||||
from pypdf import PdfReader # type: ignore
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
try:
|
||||
reader = PdfReader(str(path))
|
||||
return [
|
||||
text if has_usable_text(text) else ""
|
||||
for text in ((page.extract_text() or "") for page in reader.pages)
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
|
||||
def extract_pdf_text_naive(path: Path) -> list[str]:
|
||||
data = path.read_bytes()
|
||||
chunks: list[bytes] = []
|
||||
for match in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", data, re.S):
|
||||
stream = match.group(1)
|
||||
try:
|
||||
chunks.append(zlib.decompress(stream))
|
||||
except Exception:
|
||||
chunks.append(stream)
|
||||
|
||||
decoded = "\n".join(chunk.decode("latin-1", errors="ignore") for chunk in chunks)
|
||||
if not decoded:
|
||||
decoded = data.decode("latin-1", errors="ignore")
|
||||
|
||||
text_tokens = re.findall(r"\(([^()]*)\)", decoded)
|
||||
text = "\n".join(token.replace(r"\)", ")").replace(r"\(", "(") for token in text_tokens)
|
||||
|
||||
page_count = count_pdf_pages(path)
|
||||
if not has_usable_text(text):
|
||||
return ["" for _ in range(max(page_count, 1))]
|
||||
if "\f" in text and page_count > 1:
|
||||
parts = [clean_text(part) for part in text.split("\f")]
|
||||
if len(parts) <= page_count * 2 and any(has_usable_text(part) for part in parts):
|
||||
return [part if has_usable_text(part) else "" for part in parts]
|
||||
if page_count <= 1:
|
||||
return [clean_text(text)]
|
||||
return [clean_text(text)] + ["" for _ in range(max(page_count - 1, 0))]
|
||||
|
||||
def render_pdf_pages_to_images(path: Path, max_pages: int | None = None) -> list[Path]:
|
||||
try:
|
||||
import fitz # type: ignore
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
rendered: list[Path] = []
|
||||
try:
|
||||
PREVIEW_DIR.mkdir(exist_ok=True)
|
||||
document = fitz.open(path)
|
||||
for index, page in enumerate(document):
|
||||
if max_pages is not None and index >= max_pages:
|
||||
break
|
||||
pix = page.get_pixmap(matrix=fitz.Matrix(PDF_RENDER_SCALE, PDF_RENDER_SCALE), alpha=False)
|
||||
output = PREVIEW_DIR / f"{path.stem}-page-{index + 1}.png"
|
||||
pix.save(output)
|
||||
rendered.append(output)
|
||||
except Exception:
|
||||
return rendered
|
||||
return rendered
|
||||
|
||||
|
||||
def extract_image_text(path: Path) -> str:
|
||||
try:
|
||||
from PIL import Image # type: ignore
|
||||
import pytesseract # type: ignore
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
try:
|
||||
image = Image.open(path)
|
||||
for language in ("por+eng", "eng"):
|
||||
try:
|
||||
text = pytesseract.image_to_string(image, lang=language)
|
||||
except Exception:
|
||||
continue
|
||||
if has_usable_text(text):
|
||||
return text
|
||||
return ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def count_pdf_pages(path: Path) -> int:
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError:
|
||||
return 0
|
||||
matches = re.findall(rb"/Type\s*/Page\b", data)
|
||||
return len(matches)
|
||||
|
||||
|
||||
def detect_documents(pages: list[PageSource], original_name: str) -> list[DetectedDocumentCandidate]:
|
||||
candidates: list[DetectedDocumentCandidate] = []
|
||||
for page in pages:
|
||||
blocks = segment_blocks(page.text)
|
||||
if not blocks:
|
||||
if page.source_kind == "pdf" and not page.text.strip():
|
||||
continue
|
||||
location = f"pagina {page.page_number}"
|
||||
if page.image_path:
|
||||
location += f" ({page.image_path.name})"
|
||||
candidates.append(
|
||||
build_candidate(
|
||||
original_name,
|
||||
page.page_number,
|
||||
location,
|
||||
"",
|
||||
forced_confidence="low",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
for block_index, block in enumerate(blocks, start=1):
|
||||
location = f"pagina {page.page_number}, bloco {block_index}"
|
||||
candidates.append(build_candidate(original_name, page.page_number, location, block))
|
||||
return candidates
|
||||
|
||||
|
||||
def segment_blocks(text: str) -> list[str]:
|
||||
cleaned = clean_text(text)
|
||||
if not has_usable_text(cleaned):
|
||||
return []
|
||||
|
||||
lines = [line.strip() for line in cleaned.splitlines() if line.strip()]
|
||||
anchor_indexes = [
|
||||
index for index, line in enumerate(lines)
|
||||
if START_ANCHOR_RE.search(line) or CNPJ_RE.search(line)
|
||||
]
|
||||
|
||||
starts: list[int] = []
|
||||
for index in anchor_indexes:
|
||||
start = max(index - 1, 0) if CNPJ_RE.search(lines[index]) else index
|
||||
if not starts or start - starts[-1] > 3:
|
||||
starts.append(start)
|
||||
|
||||
if len(starts) <= 1:
|
||||
return [cleaned]
|
||||
|
||||
blocks: list[str] = []
|
||||
for position, start in enumerate(starts):
|
||||
end = starts[position + 1] if position + 1 < len(starts) else len(lines)
|
||||
block = "\n".join(lines[start:end]).strip()
|
||||
if block:
|
||||
blocks.append(block)
|
||||
return blocks[:MAX_BLOCKS_PER_PAGE]
|
||||
|
||||
|
||||
def build_candidate(
|
||||
original_name: str,
|
||||
page_number: int | None,
|
||||
location: str,
|
||||
raw_text: str,
|
||||
forced_confidence: str | None = None,
|
||||
) -> DetectedDocumentCandidate:
|
||||
purchase_date = extract_date(raw_text)
|
||||
supplier = extract_supplier(raw_text)
|
||||
total = extract_total(raw_text)
|
||||
|
||||
fields = {
|
||||
"purchase_date": "high" if purchase_date else "low",
|
||||
"supplier_name": "medium" if supplier else "low",
|
||||
"total_paid": "high" if total is not None and has_total_label(raw_text) else ("medium" if total is not None else "low"),
|
||||
}
|
||||
confidence = forced_confidence or overall_confidence(fields)
|
||||
return DetectedDocumentCandidate(
|
||||
source_file_name=original_name,
|
||||
source_page=page_number,
|
||||
source_location=location,
|
||||
raw_text=raw_text,
|
||||
purchase_date=purchase_date,
|
||||
supplier_name=supplier,
|
||||
total_paid=total,
|
||||
confidence=confidence,
|
||||
field_confidence=fields,
|
||||
)
|
||||
|
||||
|
||||
def extract_date(text: str) -> str | None:
|
||||
match = DATE_RE.search(text)
|
||||
if not match:
|
||||
return None
|
||||
day, month, year = match.groups()
|
||||
if len(year) == 2:
|
||||
year = f"20{year}"
|
||||
return f"{int(year):04d}-{int(month):02d}-{int(day):02d}"
|
||||
|
||||
|
||||
def extract_supplier(text: str) -> str | None:
|
||||
for line in clean_text(text).splitlines()[:8]:
|
||||
value = line.strip(" :-")
|
||||
if not value:
|
||||
continue
|
||||
if should_skip_supplier_line(value):
|
||||
continue
|
||||
if any(ch.isalpha() for ch in value):
|
||||
return value[:120]
|
||||
return None
|
||||
|
||||
|
||||
def should_skip_supplier_line(line: str) -> bool:
|
||||
upper = line.upper()
|
||||
if CNPJ_RE.search(line) or DATE_RE.search(line) or MONEY_RE.search(line):
|
||||
return True
|
||||
blocked = ["CUPOM", "NFC", "SAT", "DANFE", "EXTRATO", "VALOR", "TOTAL", "CHAVE", "ENDERECO"]
|
||||
return any(token in upper for token in blocked)
|
||||
|
||||
|
||||
def extract_total(text: str) -> float | None:
|
||||
lines = [line.strip() for line in clean_text(text).splitlines() if line.strip()]
|
||||
labelled: list[float] = []
|
||||
all_values: list[float] = []
|
||||
for line in lines:
|
||||
values = [parse_money(match.group(1)) for match in MONEY_RE.finditer(line)]
|
||||
values = [value for value in values if value is not None]
|
||||
all_values.extend(values)
|
||||
if LABEL_RE.search(line):
|
||||
labelled.extend(values)
|
||||
|
||||
if labelled:
|
||||
return labelled[-1]
|
||||
if all_values:
|
||||
return all_values[-1]
|
||||
return None
|
||||
|
||||
|
||||
def parse_money(value: str) -> float | None:
|
||||
normalized = value.strip()
|
||||
if "," in normalized:
|
||||
normalized = normalized.replace(".", "").replace(",", ".")
|
||||
try:
|
||||
return round(float(normalized), 2)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def has_total_label(text: str) -> bool:
|
||||
return LABEL_RE.search(text) is not None
|
||||
|
||||
|
||||
def overall_confidence(fields: dict[str, str]) -> str:
|
||||
values = list(fields.values())
|
||||
if all(value == "high" for value in values):
|
||||
return "high"
|
||||
if values.count("low") >= 2:
|
||||
return "low"
|
||||
return "medium"
|
||||
|
||||
|
||||
def clean_text(text: str) -> str:
|
||||
text = text.replace("\x00", " ")
|
||||
lines = [re.sub(r"\s+", " ", line).strip() for line in text.splitlines()]
|
||||
return "\n".join(line for line in lines if line)
|
||||
|
||||
|
||||
def has_usable_text(text: str) -> bool:
|
||||
cleaned = clean_text(text)
|
||||
if len(cleaned) < 12:
|
||||
return False
|
||||
|
||||
allowed_extra = set("???????????????????????????????????????????????????$??")
|
||||
ordinary = sum(
|
||||
1
|
||||
for ch in cleaned
|
||||
if ch.isascii() or ch in allowed_extra
|
||||
)
|
||||
printable = sum(1 for ch in cleaned if ch.isprintable())
|
||||
alnum = sum(1 for ch in cleaned if ch.isalnum())
|
||||
letters = sum(1 for ch in cleaned if ch.isalpha())
|
||||
length = max(len(cleaned), 1)
|
||||
if ordinary / length < 0.85:
|
||||
return False
|
||||
if printable / length < 0.9:
|
||||
return False
|
||||
if alnum / length < 0.35:
|
||||
return False
|
||||
if letters < 3 and not (DATE_RE.search(cleaned) or MONEY_RE.search(cleaned) or CNPJ_RE.search(cleaned)):
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-24
|
||||
@@ -0,0 +1,33 @@
|
||||
## Context
|
||||
|
||||
`fiscal_documents` já possui a coluna `categoria_id` (FK nullable para `categoria`), preenchida hoje apenas no fluxo de importação em lote (`confirm_batch`, auto-categorização por palavra-chave). O cadastro manual (`/documents/new`) e as duas telas de listagem (Dashboard "Últimos documentos" e `/documents`) não expõem categoria. Não há ORM — o acesso a dados é feito com `sqlite3` puro em `app/database.py`. O padrão de select populado a partir de tabela já existe no filtro de categoria da Dashboard (`list_categorias`, ordenado por `id ASC`).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Permitir escolher a categoria ao criar um documento manualmente em `/documents/new`.
|
||||
- Persistir essa escolha em `fiscal_documents.categoria_id`.
|
||||
- Exibir a coluna "Categoria" (nome, não id) logo após "Fornecedor" nas tabelas da Dashboard e de `/documents`.
|
||||
- Ordenar o select de categorias por nome (`categoria` ASC), não por `id`, para facilitar a localização pelo usuário.
|
||||
|
||||
**Non-Goals:**
|
||||
- Não altera o fluxo de auto-categorização por palavra-chave já existente em `confirm_batch`.
|
||||
- Não adiciona edição de categoria em documentos já existentes fora do formulário atual (sem tela de edição dedicada nesta mudança, a menos que já exista `update_fiscal` chamada por uma tela de edição — nesse caso o mesmo campo é reaproveitado).
|
||||
- Não altera o schema do banco (coluna já existe).
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Ordenação do select por nome, não por id**: `list_categorias` atual faz `ORDER BY id ASC`. Para o novo select em `/documents/new` será usada uma consulta (nova função `list_categorias_ordenadas_por_nome` ou parâmetro de ordenação em `list_categorias`) com `ORDER BY categoria ASC`, conforme pedido explícito do usuário. Optamos por não alterar o `ORDER BY` do `list_categorias` existente (usado no filtro da Dashboard) para não mudar comportamento não solicitado; em vez disso adicionamos uma variante/parâmetro.
|
||||
2. **Exposição do nome da categoria nas listagens via LEFT JOIN**: em vez de fazer uma segunda query por linha (N+1), a consulta usada por Dashboard e `/documents` (`list_fiscal` ou equivalente) passa a fazer `LEFT JOIN categoria ON fiscal_documents.categoria_id = categoria.id`, trazendo `categoria.categoria AS categoria_nome`. LEFT JOIN (não INNER) para não esconder documentos sem categoria.
|
||||
3. **Campo opcional no formulário**: o `<select name="categoria_id">` inclui uma opção vazia/"Sem categoria" para não obrigar o usuário a categorizar manualmente, mantendo compatibilidade com documentos sem categoria.
|
||||
4. **Reuso de `create_fiscal`/`update_fiscal`**: adicionar parâmetro opcional `categoria_id=None` a essas funções em vez de criar novas funções, minimizando duplicação.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Alterar `list_fiscal` para incluir JOIN pode impactar outros chamadores que dependem do shape atual do retorno (linha como tupla/Row)] → Mitigação: adicionar apenas colunas extras ao final do SELECT (não remover/reordenar colunas existentes) e verificar todos os call sites de `list_fiscal` antes de alterar.
|
||||
- [Footer com colspan fixo em `documents_list.html` pode quebrar visualmente ao adicionar coluna] → Mitigação: ajustar o colspan do footer/total ao adicionar a nova `<th>`.
|
||||
- [Usuário pode confundir "Sem categoria" com a categoria "Não Encontrado" (id=1) já existente] → Mitigação: usar valor vazio (NULL) para "Sem categoria" no select, distinto da categoria seedada "Não Encontrado".
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Sem migração de dados necessária (coluna já existe). Deploy é apenas código: rotas, templates e função de listagem. Rollback trivial (reverter os arquivos alterados), pois nenhuma escrita de schema é feita.
|
||||
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
Atualmente a categoria de um documento fiscal só é atribuída automaticamente durante a importação em lote (`confirm_batch`), com base em palavra-chave. Ao cadastrar um documento manualmente pela tela "Novo Documento" (`/documents/new`) não há como escolher a categoria, então o registro fica sempre com `categoria_id` nulo. Além disso, nem a Dashboard ("Últimos documentos") nem a listagem `/documents` exibem a categoria do lançamento, dificultando a conferência visual de como os documentos foram classificados.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Adicionar campo select "Categoria" ao formulário de novo documento (`/documents/new`), populado a partir da tabela `categoria` ordenada por nome (ASC), permitindo escolher a categoria no cadastro manual.
|
||||
- Persistir o `categoria_id` selecionado ao criar (e editar, quando aplicável) o documento fiscal, passando a informação para `create_fiscal`/`update_fiscal`.
|
||||
- Incluir coluna "Categoria" na tabela "Últimos documentos" do Dashboard, posicionada logo após a coluna "Fornecedor".
|
||||
- Incluir coluna "Categoria" na tabela de listagem da rota `/documents`, posicionada logo após a coluna "Fornecedor".
|
||||
- Ajustar a consulta de listagem de documentos (`list_fiscal` ou variante) para trazer o nome da categoria via LEFT JOIN com a tabela `categoria`, já que a consulta atual não expõe esse dado.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `document-categorization`: Cobre a seleção de categoria no formulário de novo documento e a persistência do vínculo documento-categoria.
|
||||
- `document-listing-category-column`: Cobre a exibição da coluna "Categoria" nas listagens de documentos (Dashboard "Últimos documentos" e rota `/documents`), logo após a coluna "Fornecedor".
|
||||
|
||||
### Modified Capabilities
|
||||
(nenhuma — não há specs existentes em `openspec/specs/`; os itens acima são tratados como novas capacidades)
|
||||
|
||||
## Impact
|
||||
|
||||
- **Código afetado**: `app/routes/documents_routes.py` (rota `/documents/new` e `create_document`), `app/templates/document_form.html`, `app/database.py` (`create_fiscal`, `update_fiscal`, `list_fiscal`, uso de `list_categorias`), `app/routes/dashboard_routes.py`, `app/templates/dashboard.html`, `app/templates/documents_list.html`.
|
||||
- **Banco de dados**: nenhuma alteração de schema — a coluna `fiscal_documents.categoria_id` já existe; apenas passa a ser preenchida também no fluxo manual.
|
||||
- **Compatibilidade**: campo "Categoria" no formulário é opcional (documentos sem categoria continuam válidos, exibindo "Não Encontrado" ou vazio); sem impacto em dados existentes.
|
||||
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Seleção de categoria no cadastro de documento
|
||||
O formulário de novo documento, na rota `/documents/new`, SHALL exibir um campo select "Categoria" populado com todos os registros da tabela `categoria`, ordenados por nome (`categoria`) em ordem ascendente (ASC).
|
||||
|
||||
#### Scenario: Formulário exibe categorias ordenadas por nome
|
||||
- **WHEN** o usuário acessa a rota `/documents/new`
|
||||
- **THEN** o select "Categoria" é exibido com as opções carregadas da tabela `categoria`, ordenadas alfabeticamente (ASC) pelo campo `categoria`
|
||||
|
||||
#### Scenario: Campo categoria é opcional
|
||||
- **WHEN** o usuário envia o formulário de novo documento sem selecionar nenhuma categoria
|
||||
- **THEN** o documento é criado com sucesso e `categoria_id` fica nulo (sem categoria)
|
||||
|
||||
### Requirement: Persistência da categoria selecionada
|
||||
Ao submeter o formulário de novo documento com uma categoria selecionada, o sistema SHALL salvar o `id` da categoria escolhida no campo `categoria_id` do documento fiscal criado.
|
||||
|
||||
#### Scenario: Categoria selecionada é persistida
|
||||
- **WHEN** o usuário seleciona uma categoria no formulário e submete o novo documento
|
||||
- **THEN** o registro criado em `fiscal_documents` possui `categoria_id` igual ao id da categoria selecionada
|
||||
@@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Coluna Categoria na Dashboard
|
||||
A tabela "Últimos documentos" da Dashboard SHALL exibir uma coluna "Categoria" posicionada imediatamente após a coluna "Fornecedor", mostrando o nome da categoria do documento (ou vazio/"Não Encontrado" quando não houver categoria associada).
|
||||
|
||||
#### Scenario: Coluna Categoria aparece após Fornecedor na Dashboard
|
||||
- **WHEN** o usuário acessa a Dashboard
|
||||
- **THEN** a tabela "Últimos documentos" exibe as colunas na ordem: Data, Fornecedor, Categoria, Valor (demais colunas mantidas), com "Categoria" logo após "Fornecedor"
|
||||
|
||||
#### Scenario: Documento sem categoria exibido corretamente
|
||||
- **WHEN** um documento listado na Dashboard possui `categoria_id` nulo
|
||||
- **THEN** a célula da coluna "Categoria" é exibida vazia ou com um indicador de "sem categoria", sem gerar erro
|
||||
|
||||
### Requirement: Coluna Categoria na listagem de documentos
|
||||
A tabela de listagem da rota `/documents` SHALL exibir uma coluna "Categoria" posicionada imediatamente após a coluna "Fornecedor", mostrando o nome da categoria do documento.
|
||||
|
||||
#### Scenario: Coluna Categoria aparece após Fornecedor em /documents
|
||||
- **WHEN** o usuário acessa a rota `/documents`
|
||||
- **THEN** a tabela de documentos exibe as colunas na ordem: Data, Fornecedor, Categoria, Valor, Origem, Ações (demais colunas mantidas), com "Categoria" logo após "Fornecedor"
|
||||
|
||||
#### Scenario: Documento sem categoria exibido corretamente em /documents
|
||||
- **WHEN** um documento listado em `/documents` possui `categoria_id` nulo
|
||||
- **THEN** a célula da coluna "Categoria" é exibida vazia ou com um indicador de "sem categoria", sem gerar erro
|
||||
@@ -0,0 +1,34 @@
|
||||
## 1. Camada de dados (app/database.py)
|
||||
|
||||
- [x] 1.1 Adicionar função (ou parâmetro) para listar categorias ordenadas por nome ASC (`ORDER BY categoria ASC`), reutilizando `list_categorias` como referência sem alterar seu comportamento atual.
|
||||
- [x] 1.2 Adicionar parâmetro opcional `categoria_id=None` em `create_fiscal` e persistir o valor no INSERT de `fiscal_documents`.
|
||||
- [x] 1.3 Adicionar parâmetro opcional `categoria_id` em `update_fiscal` (se existir fluxo de edição), persistindo no UPDATE.
|
||||
- [x] 1.4 Levantar todos os call sites de `list_fiscal` (ou função equivalente usada por Dashboard e `/documents`) e confirmar o shape de retorno atual antes de alterar.
|
||||
- [x] 1.5 Alterar a consulta usada pela listagem (`list_fiscal` ou variante) para incluir `LEFT JOIN categoria ON fiscal_documents.categoria_id = categoria.id`, adicionando `categoria.categoria AS categoria_nome` ao final do SELECT sem remover/reordenar colunas existentes.
|
||||
|
||||
## 2. Formulário de novo documento (/documents/new)
|
||||
|
||||
- [x] 2.1 Em `app/routes/documents_routes.py`, na rota GET de `/documents/new`, buscar a lista de categorias ordenadas por nome (via 1.1) e passar ao template.
|
||||
- [x] 2.2 Em `app/templates/document_form.html`, adicionar `<select name="categoria_id">` com opção vazia "Sem categoria" seguida das opções carregadas, próximo aos demais campos do formulário.
|
||||
- [x] 2.3 Na rota POST `create_document`, ler `categoria_id` do form (tratando string vazia como `None`) e repassar para `create_fiscal`.
|
||||
- [x] 2.4 Testar manualmente: criar documento sem categoria e criar documento com categoria selecionada; confirmar persistência via consulta ao banco.
|
||||
|
||||
## 3. Coluna Categoria na Dashboard
|
||||
|
||||
- [x] 3.1 Em `app/routes/dashboard_routes.py`, confirmar que os dados de "Últimos documentos" já incluem `categoria_nome` após a alteração da query (item 1.5); ajustar se necessário.
|
||||
- [x] 3.2 Em `app/templates/dashboard.html`, adicionar `<th>Categoria</th>` logo após `<th>Fornecedor</th>` no cabeçalho da tabela "Últimos documentos".
|
||||
- [x] 3.3 Adicionar a célula correspondente (`{{ item.categoria_nome or '' }}` ou equivalente) na mesma posição nas linhas da tabela.
|
||||
- [x] 3.4 Validar visualmente que a coluna aparece corretamente para documentos com e sem categoria, e que o restante do layout não quebra.
|
||||
|
||||
## 4. Coluna Categoria na listagem /documents
|
||||
|
||||
- [x] 4.1 Confirmar que a rota `/documents` (`list_documents`) já recebe `categoria_nome` via a query alterada (item 1.5).
|
||||
- [x] 4.2 Em `app/templates/documents_list.html`, adicionar `<th>Categoria</th>` logo após `<th>Fornecedor</th>` no cabeçalho.
|
||||
- [x] 4.3 Adicionar a célula correspondente na mesma posição nas linhas da tabela.
|
||||
- [x] 4.4 Ajustar o `colspan` do footer/linha de total, se existir, para refletir a coluna adicional.
|
||||
- [x] 4.5 Validar visualmente a listagem completa, incluindo documentos com e sem categoria.
|
||||
|
||||
## 5. Verificação final
|
||||
|
||||
- [x] 5.1 Rodar a aplicação localmente e testar o fluxo completo: cadastrar documento com categoria em `/documents/new`, confirmar exibição correta em `/documents` e na Dashboard.
|
||||
- [x] 5.2 Rodar a suíte de testes existente (`tests/`) e corrigir eventuais quebras relacionadas às funções alteradas em `database.py`.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-25
|
||||
@@ -0,0 +1,75 @@
|
||||
## Context
|
||||
|
||||
LerNotaFiscal persiste hoje uma data completa (`purchase_date TEXT NOT NULL`, ISO `YYYY-MM-DD`) em `fiscal_documents`, e `purchase_date TEXT` (nullable) em `detected_documents` (staging). O acesso a dados é `sqlite3` puro sem ORM/migrations (`app/database.py`): o schema vive numa string `SCHEMA` rodada via `executescript()` em toda abertura de conexão, e o único precedente de evolução de schema é aditivo (`ALTER TABLE ... ADD COLUMN`, guardado por `PRAGMA table_info`, usado para `categoria_id`). Nunca houve remoção/rebuild de coluna neste projeto.
|
||||
|
||||
O campo de data hoje passa por: extração IA (`app/ai_extraction.py`, prompt pedindo `"data_compra"` completo) ou fallback OCR/regex (`lernotafiscal/extraction.py`, `DATE_RE` dd/mm/aaaa); normalização central em `app/dates.py` (`resolve_purchase_date`, com fallback "1º dia do mês corrente" quando ilegível); persistência/CRUD (`create_fiscal`, `update_fiscal`, `confirm_batch`, `update_staged`); agregação (`monthly_totals` via `substr(purchase_date,1,7)`); filtro/ordenação por intervalo (`_fiscal_where`, `FISCAL_SORT_COLUMNS`); e exibição (`format_br_date`, coluna "Data" em `dashboard.html`/`documents_list.html`, `<input type="date">` em `staging.html`/`document_form.html`).
|
||||
|
||||
Esta mudança substitui esse conceito por competência (mês/ano), refletindo que o dia nunca foi de fato relevante para o negócio (o Dashboard já converte para mês na exibição, e o fallback já assume "mês corrente" quando a data é ilegível).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Persistir `mes` (1-12) e `ano` (ex. 2026) como colunas nativas inteiras em `fiscal_documents` (obrigatórias) e `detected_documents` (opcionais, como `purchase_date` era), substituindo `purchase_date` por completo.
|
||||
- Exigir confirmação explícita de "Competência: Mês/Ano" via dois `<select>` (Mês: Jan-Dez; Ano: 2026-2030) na tela de revisão, antes de permitir confirmar o lote — mesmo em documentos onde a IA/OCR já sugeriu um valor.
|
||||
- Preservar a capacidade de filtrar/ordenar/agrupar por período no Dashboard e em `/documents`, agora em granularidade de mês em vez de dia.
|
||||
- Migrar dados existentes (backfill `mes`/`ano` a partir do `purchase_date` atual) sem perda de informação de competência.
|
||||
|
||||
**Non-Goals:**
|
||||
- Não se propõe manter o dia da compra em lugar nenhum (nem como campo oculto/auditoria) — é descartado deliberadamente.
|
||||
- Não se propõe suportar competências fora do intervalo 2026-2030 no seletor (ver Open Questions sobre extensibilidade do range de anos).
|
||||
- Não se propõe alterar a lógica de categorização (`categorization.py`) — confirmado que não usa data/competência.
|
||||
- Não se propõe migrar `lernotafiscal/db.py` (módulo legado, não usado pelo fluxo atual além da constante `PREVIEW_DIR`).
|
||||
|
||||
## Decisions
|
||||
|
||||
**1. Duas colunas inteiras (`mes INTEGER`, `ano INTEGER`), não uma única coluna `competencia TEXT` (`YYYY-MM`).**
|
||||
Racional: o pedido explícito é "criar campos: mes e ano", e os `<select>` da UI (mês por nome, ano por valor) mapeiam naturalmente para dois valores inteiros independentes, evitando parsing de string em toda leitura. Comparações/ordenação também ficam mais simples com inteiros (`ORDER BY ano, mes`) do que com `substr`/`LIKE` em texto. Alternativa considerada: coluna única `competencia TEXT` no formato `YYYY-MM` (mais parecida com o `substr(purchase_date,1,7)` já usado hoje) — rejeitada por divergir do pedido explícito e por exigir parsing de string em toda query de filtro/ordenação por ano isolado.
|
||||
|
||||
**2. IA e OCR continuam extraindo uma data completa do texto/imagem; o descarte do dia acontece na camada de normalização, não no prompt/regex.**
|
||||
Racional: documentos fiscais reais quase sempre exibem uma data completa (dd/mm/aaaa) — pedir ao modelo de IA para "raciocinar" apenas sobre mês/ano a partir do texto bruto é uma tarefa estritamente mais difícil e menos confiável do que extrair a data completa (already validado hoje por `_is_plausible_purchase_date`) e simplesmente descartar o dia depois. O mesmo vale para o regex `DATE_RE` (dd/mm/aaaa), que já funciona bem. Portanto: `app/ai_extraction.py` e `lernotafiscal/extraction.py` mantêm a extração de data completa como está; a função de normalização (`app/dates.py`) é que passa a devolver `(mes, ano)` em vez de uma string de data, descartando o componente de dia. Alternativa considerada: mudar o prompt da IA para pedir diretamente `mes_compra`/`ano_compra` — rejeitada por risco de o modelo confundir nomes de mês/abreviações ou é induzido a "inventar" quando o texto está truncado, perdendo a checagem de plausibilidade que hoje já opera sobre data completa.
|
||||
|
||||
**3. `app/dates.py` ganha `resolve_competencia(date_raw, reference=None) -> tuple[int, int]`, substituindo `resolve_purchase_date`.**
|
||||
Racional: mantém o mesmo ponto único de normalização/fallback já estabelecido (usado tanto no upload quanto no CRUD manual), apenas trocando o tipo de retorno. O fallback "1º dia do mês corrente" vira "mês/ano corrente" — mesma semântica, um passo mais simples (não precisa mais inventar um dia fictício). `parse_date` é ajustado para aceitar os formatos existentes (ISO, `dd/mm/aaaa`) e devolver `(mes, ano)`; `format_br_date` é substituído por `format_competencia(mes, ano) -> str` (ex. `"jul/2026"`), reaproveitando a lista de abreviações de mês que já existe em `_MESES` (`dashboard_routes.py`) — essa lista é promovida para `app/dates.py` como fonte única, e `dashboard_routes.py`/`_mes_label` passam a importá-la de lá em vez de duplicar.
|
||||
|
||||
**4. Migração de schema via rebuild de tabela (não `ALTER TABLE ... DROP COLUMN`), para não depender da versão do SQLite.**
|
||||
Racional: `DROP COLUMN` só existe a partir do SQLite 3.35.0 (2021); o projeto não fixa/verifica versão mínima de SQLite hoje, e usar rebuild (criar tabela nova com o schema final, copiar dados com `INSERT INTO nova_tabela SELECT ...` computando `mes`/`ano` a partir de `purchase_date`, `DROP TABLE` antiga, `ALTER TABLE ... RENAME TO`, recriar índices) é portável para qualquer versão e é a técnica recomendada pela própria documentação do SQLite para mudanças estruturais. A migração roda dentro de `init_db()`, guardada por uma checagem `PRAGMA table_info` (mesma convenção já usada para `categoria_id`): se `purchase_date` ainda existir em `fiscal_documents`/`detected_documents`, executa o rebuild uma única vez. Alternativa considerada: tentar `ALTER TABLE ... DROP COLUMN purchase_date` diretamente — rejeitada pelo risco de quebrar em instalações com SQLite mais antigo (ex. bibliotecas do sistema operacional legadas empacotadas com Python).
|
||||
|
||||
**5. Índice `idx_fiscal_date` é substituído por `idx_fiscal_competencia ON fiscal_documents(ano, mes)`.**
|
||||
Racional: mesma finalidade (acelerar filtro/ordenação por período), agora alinhado às colunas nativas; ordem `(ano, mes)` favorece tanto filtro por ano isolado quanto por intervalo completo de competência.
|
||||
|
||||
**6. Filtro por intervalo (`start`/`end`) em `/documents` é substituído por filtro de competência usando a expressão `(ano * 12 + mes)` como chave comparável, sem nova coluna derivada.**
|
||||
Racional: preserva a capacidade de "listar de Jan/2026 até Jun/2026" com uma única expressão SQL indexável de forma equivalente (`ano*12+mes BETWEEN ? AND ?`), sem precisar manter uma terceira coluna redundante sincronizada com `mes`/`ano`. A UI de filtro em `documents_list.html` passa a ter dois pares de `<select>` Mês/Ano ("De" e "Até") em vez de dois `<input type="date">`. `FISCAL_SORT_COLUMNS` passa a expor `ano`/`mes` (ordenação primária por `ano, mes`) em vez de `purchase_date`. Alternativa considerada: manter apenas filtro por ano (sem intervalo) — rejeitada por reduzir uma funcionalidade hoje existente sem necessidade.
|
||||
|
||||
**7. `monthly_totals` agrupa diretamente por `ano, mes` (SQL `GROUP BY ano, mes ORDER BY ano, mes`), eliminando o `substr(purchase_date, 1, 7)`.**
|
||||
Racional: elimina dependência de formatação de string para agregação, mais robusto e mais rápido (usa o novo índice). O label exibido (`_mes_label`) passa a montar a partir das colunas nativas via `format_competencia`.
|
||||
|
||||
**8. Formato de exibição segue exatamente o exemplo dado pelo usuário: mês abreviado capitalizado + ano com 2 dígitos (ex. `"Jun/26"`, `"Jul/26"`), substituindo o formato hoje usado por `_mes_label` (`"jul/2026"`, minúsculo/4 dígitos).**
|
||||
Racional: o pedido original especifica esse formato de forma explícita e com dois exemplos consistentes entre si, então é tratado como requisito e não como abreviação informal. `format_competencia(mes, ano) -> str` monta a string a partir da lista `_MESES` (capitalizada, ex. `["Jan","Fev","Mar",...]`) e do ano truncado para 2 dígitos (`ano % 100`, formatado com zero à esquerda). `_mes_label`/`dashboard.html` passam a usar este novo formato, uma mudança visual em relação ao Dashboard atual.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Risk]** Rebuild de tabela em `init_db()` roda em toda conexão até a checagem `PRAGMA table_info` confirmar que a migração já ocorreu; se o processo for interrompido no meio do rebuild (`INSERT ... SELECT` + `DROP` + `RENAME`), o banco pode ficar em estado inconsistente (tabela temporária órfã). → **Mitigação**: envolver os passos do rebuild em uma única transação (`BEGIN`/`COMMIT`), e usar nomes de tabela temporária previsíveis (`fiscal_documents_new`) verificados/limpos no início da rotina caso uma execução anterior tenha falhado a meio caminho.
|
||||
- **[Risk]** Perda de granularidade: filtros/relatórios que hoje poderiam (em teoria) restringir por dia específico deixam de ser possíveis. → **Mitigação**: aceito como consequência intencional da mudança (já sinalizado como **BREAKING** no proposal); nenhum uso atual do produto depende de granularidade diária (Dashboard e listagem já operam em nível de mês/intervalo).
|
||||
- **[Risk]** `scripts/migrate_notas.py` e testes (`tests/test_app.py`, `tests/test_ingestion.py`, `tests/test_categorization.py`) chamam `create_fiscal`/`update_fiscal`/`resolve_purchase_date` com `purchase_date=...` diretamente; ficarão quebrados até serem atualizados. → **Mitigação**: listado explicitamente nas tasks; não há uso em produção desses scripts fora de execução manual, então não bloqueiam o deploy da aplicação principal, mas devem ser corrigidos antes de considerar a mudança completa.
|
||||
- **[Trade-off]** Descartar o dia na normalização (Decisão 2) em vez de mudar o prompt da IA mantém mais código de extração inalterado, mas significa que a "plausibilidade" (`_is_plausible_purchase_date`) continua avaliando uma data completa que depois é truncada — a validação de mês/ano futuro/passado precisa ser revisada para operar em termos de mês/ano (ex. "mês/ano não pode ser mais que 3 meses no futuro" em vez de "3 dias").
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Adicionar rebuild de `fiscal_documents` e `detected_documents` em `app/database.py`/`init_db()`: criar tabelas `_new` com `mes INTEGER`/`ano INTEGER` (NOT NULL em `fiscal_documents`, nullable em `detected_documents`), copiar dados computando `mes = CAST(substr(purchase_date,6,2) AS INTEGER)`, `ano = CAST(substr(purchase_date,1,4) AS INTEGER)`, remover a tabela antiga e renomear; recriar `idx_fiscal_competencia`; tudo guardado por `PRAGMA table_info` (só roda se `purchase_date` ainda existir) e dentro de uma transação.
|
||||
2. Atualizar `app/dates.py` (`resolve_competencia`, `format_competencia`, ajuste de `_is_plausible_purchase_date` para mês/ano) e promover `_MESES` para lá.
|
||||
3. Atualizar `app/database.py`: `insert_detected`, `update_staged`, `confirm_batch`, `create_fiscal`, `update_fiscal`, `_fiscal_where`, `FISCAL_SORT_COLUMNS`, `monthly_totals`, `category_totals` para usar `mes`/`ano`.
|
||||
4. Atualizar `app/ingestion.py` (`_candidate_to_raw`) e dataclasses (`RawExtraction`, `DetectedDocumentCandidate`) apenas na nomeação/tipo de campo repassado a `resolve_competencia`, sem mudar a extração de data em si (Decisão 2).
|
||||
5. Atualizar `app/routes/upload_routes.py` e `app/templates/staging.html`: novos `<select>` Mês/Ano por linha, tornando a escolha de competência um passo obrigatório antes de habilitar a confirmação do lote.
|
||||
6. Atualizar `app/routes/documents_routes.py` e `app/templates/document_form.html` (mesmo padrão de `<select>`), e `app/templates/documents_list.html`/`dashboard.html` (filtros e exibição por competência).
|
||||
7. Atualizar `app/routes/dashboard_routes.py` (`monthly_totals`, `_mes_label` → `format_competencia`).
|
||||
8. Atualizar `scripts/migrate_notas.py` e os testes afetados (`tests/test_app.py`, `tests/test_ingestion.py`, `tests/test_categorization.py`) para o novo schema/campos.
|
||||
9. Rollback: como o rebuild remove `purchase_date` permanentemente, o rollback de código sem rollback de dados perde a competência de mês/ano recém-adotada; recomenda-se backup do arquivo SQLite antes do deploy desta mudança em produção, e reverter apenas em ambiente onde a perda de granularidade de dia (já ocorrida) é aceitável.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- O intervalo fixo de anos no seletor (2026-2030) deve ser estático no template ou calculado dinamicamente (ex. ano atual até ano atual + 4) para não exigir alteração de código todo ano? Assumido estático por ora, conforme literalmente pedido, com nota para revisão futura.
|
||||
|
||||
## Resolved
|
||||
|
||||
- **Escopo de "filtros em todas as rotas"**: confirmado com o usuário — significa apenas adaptar os filtros/agregações **existentes** (Dashboard por mês, listagem `/documents` por intervalo) para usar competência nativa. Nenhum filtro de período novo é adicionado ao Dashboard (que continua filtrando só por categoria).
|
||||
- **Alterações não commitadas no working tree**: usuário não utiliza controle de versão git neste projeto; desprezado como preocupação — não há necessidade de reconciliar/commitar nada antes da implementação.
|
||||
@@ -0,0 +1,36 @@
|
||||
## Why
|
||||
|
||||
Hoje o LerNotaFiscal extrai e persiste uma data completa (`purchase_date`, `YYYY-MM-DD`) do documento fiscal via OCR/IA, mas o valor real de negócio para o usuário é apenas o **mês de referência do gasto** (competência), não o dia exato. A extração de dia é frequentemente a fonte de baixa confiança/ilegibilidade no OCR (regex `DATE_RE` e checagem `_is_plausible_purchase_date`), o dashboard já converte a data para "mês/ano" para exibição (`_mes_label`, `substr(purchase_date, 1, 7)`), e o formulário manual já reforça a convenção "1º dia do mês corrente" como fallback. Trocar o campo de data por competência (mês/ano) simplifica a extração, elimina uma fonte de erro/ilegibilidade desnecessária e alinha o dado armazenado com o que o Dashboard e os relatórios realmente precisam.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING**: Remover a coluna `purchase_date` (data completa) de `fiscal_documents` e `detected_documents`, substituindo por dois campos: `mes` (inteiro 1-12) e `ano` (inteiro, ex. 2026).
|
||||
- Atualizar a extração via IA (`app/ai_extraction.py`) e o fallback OCR (`lernotafiscal/extraction.py`) para não mais extrair dia — apenas identificar mês/ano do documento quando possível (ex. a partir da mesma data encontrada no texto, descartando o dia).
|
||||
- Adicionar uma etapa de confirmação obrigatória antes do botão "Enviar/Revisar": perguntar explicitamente a **Competência: Mês/Ano** de cada documento na tela de revisão (`staging.html`), com dois campos `<select>` — Mês (Jan, Fev, Mar, Abr, Mai, Jun, Jul, Ago, Set, Out, Nov, Dez) e Ano (2026, 2027, 2028, 2029, 2030) — pré-selecionados com o valor detectado quando disponível, permitindo correção manual antes de confirmar o lote.
|
||||
- Aplicar o mesmo par de selects (Mês/Ano) no formulário manual de documento (`document_form.html`), substituindo o `<input type="date">` atual.
|
||||
- Atualizar `confirm_batch`/`create_fiscal`/`update_fiscal` para gravar `mes`/`ano` em vez de `purchase_date`, com `mes` e `ano` obrigatórios (mesma garantia de não-nulo que `purchase_date` tinha).
|
||||
- Atualizar todas as agregações e filtros que hoje dependem de `purchase_date`:
|
||||
- `monthly_totals` (Dashboard: "Gastos por mês") passa a agrupar diretamente por `ano`/`mes` em vez de `substr(purchase_date, 1, 7)`.
|
||||
- `category_totals`, `_fiscal_where`, `FISCAL_SORT_COLUMNS` e a listagem `/documents` (`documents_list.html`) passam a filtrar/ordenar por `ano`/`mes` em vez de intervalo de datas (`start`/`end` do tipo date).
|
||||
- Exibição de "Competência" (ex. `Jun/2026`) substitui a coluna/célula "Data" no Dashboard e em `/documents`.
|
||||
- Atualizar scripts e testes que hoje gravam/leem `purchase_date` diretamente (`scripts/migrate_notas.py`, `tests/test_app.py`, `tests/test_ingestion.py`, `tests/test_categorization.py`) para o novo par de campos.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `document-competencia`: Cobre a captura, revisão, persistência, exibição e filtragem da competência (mês/ano) de um documento fiscal, substituindo o conceito de data completa de compra em todo o fluxo (upload → revisão → confirmação → dashboard → listagem/filtros).
|
||||
|
||||
### Modified Capabilities
|
||||
(nenhuma — não há specs existentes em `openspec/specs/`; o item acima é tratado como nova capability, que substitui integralmente o comportamento de data hoje implícito no código)
|
||||
|
||||
## Impact
|
||||
|
||||
- **Banco de dados** (`app/database.py`): remoção de `purchase_date` (e do índice `idx_fiscal_date`) de `fiscal_documents` e `detected_documents`; novas colunas `mes INTEGER NOT NULL` e `ano INTEGER NOT NULL` (com índice equivalente `idx_fiscal_competencia` em `(ano, mes)`); rebuild de tabela (SQLite não remove coluna com `ALTER TABLE` em todas as versões-alvo) com backfill dos dados existentes a partir do `purchase_date` atual antes de descartá-lo.
|
||||
- **Extração** (`app/ai_extraction.py`, `lernotafiscal/extraction.py`, `app/ingestion.py`): prompt de IA e regex OCR passam a reportar mês/ano em vez de dia completo; `RawExtraction`/`DetectedDocumentCandidate` trocam `purchase_date_raw`/`purchase_date` por `mes_raw`/`ano_raw`.
|
||||
- **Normalização** (`app/dates.py`): `resolve_purchase_date`/`parse_date`/`format_br_date` são substituídos ou adaptados para resolver/validar/formatar competência (`mes`/`ano` → "Jun/2026"), reaproveitando a lógica de fallback "mês corrente" já existente.
|
||||
- **Rotas e templates de upload/revisão** (`app/routes/upload_routes.py`, `app/templates/staging.html`): novo par de `<select>` Mês/Ano por linha, com pergunta explícita de competência antes de habilitar "Enviar"/confirmar lote.
|
||||
- **Rotas e templates de CRUD manual** (`app/routes/documents_routes.py`, `app/templates/document_form.html`): mesmo par de `<select>` Mês/Ano substituindo `<input type="date">`.
|
||||
- **Dashboard** (`app/routes/dashboard_routes.py`, `app/templates/dashboard.html`): `monthly_totals` e exibição de "Últimos documentos" passam a usar `ano`/`mes` nativos; `_mes_label` deixa de derivar de string e passa a formatar diretamente a partir das colunas.
|
||||
- **Listagem/filtros** (`app/routes/documents_routes.py`, `app/templates/documents_list.html`): filtros `start`/`end` (datas) são substituídos por filtro de competência (mês/ano ou intervalo de competência); ordenação por `purchase_date` substituída por ordenação por `(ano, mes)`.
|
||||
- **Scripts e testes**: `scripts/migrate_notas.py`, `tests/test_app.py`, `tests/test_ingestion.py`, `tests/test_categorization.py` precisam ser atualizados para o novo schema/campos.
|
||||
- **Compatibilidade**: mudança é destrutiva para dados existentes no formato antigo — exige migração/backfill de `purchase_date` para `mes`/`ano` antes da remoção da coluna; documentos sem data reconhecível hoje (fallback "1º dia do mês corrente") migram diretamente para o mês/ano correspondente.
|
||||
@@ -0,0 +1,81 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Persistência de Competência (Mês/Ano)
|
||||
O sistema SHALL persistir a competência de um documento fiscal como dois campos inteiros, `mes` (1-12) e `ano` (ex. 2026), em vez de uma data completa de compra. Em `fiscal_documents`, `mes` e `ano` SHALL ser obrigatórios (não nulos). Em `detected_documents` (staging), `mes` e `ano` SHALL permanecer opcionais enquanto o documento não tiver sido revisado/confirmado.
|
||||
|
||||
#### Scenario: Documento confirmado exige competência
|
||||
- **WHEN** um documento em staging é promovido para `fiscal_documents` via confirmação de lote
|
||||
- **THEN** o registro criado possui `mes` e `ano` preenchidos com valores válidos (mes entre 1 e 12, ano numérico)
|
||||
|
||||
#### Scenario: Documento em staging pode estar sem competência
|
||||
- **WHEN** um documento acabou de ser detectado por OCR/IA e ainda não foi revisado
|
||||
- **THEN** `mes` e/ou `ano` podem estar nulos em `detected_documents` até que o usuário revise e preencha a competência
|
||||
|
||||
### Requirement: Extração Não Persiste Mais Data Completa
|
||||
O sistema SHALL deixar de persistir o dia da compra em qualquer tabela. A extração via IA ou OCR pode continuar identificando uma data completa no texto/imagem do documento, mas o dia SHALL ser descartado antes da persistência, mantendo-se apenas mês e ano.
|
||||
|
||||
#### Scenario: Data completa detectada no documento
|
||||
- **WHEN** a extração (IA ou OCR) identifica uma data completa (dia/mês/ano) no documento
|
||||
- **THEN** o sistema armazena apenas o mês e o ano correspondentes, descartando o dia
|
||||
|
||||
### Requirement: Confirmação Obrigatória de Competência Antes do Envio
|
||||
Na tela de revisão pós-upload, o sistema SHALL exigir que o usuário confirme explicitamente a Competência (Mês/Ano) de cada documento, através de dois campos de seleção — Mês (Jan a Dez) e Ano (2026 a 2030) — antes de permitir a confirmação do lote.
|
||||
|
||||
#### Scenario: Competência pré-selecionada a partir da extração
|
||||
- **WHEN** a extração identificou um mês/ano válido para o documento
|
||||
- **THEN** os seletores de Mês e Ano na tela de revisão vêm pré-selecionados com esses valores, permitindo correção manual pelo usuário
|
||||
|
||||
#### Scenario: Competência ausente bloqueia confirmação do lote
|
||||
- **WHEN** um ou mais documentos do lote não têm Mês e Ano selecionados
|
||||
- **THEN** o sistema não permite confirmar o lote até que a competência de todos os documentos pendentes seja preenchida
|
||||
|
||||
### Requirement: Seleção de Competência no Cadastro Manual
|
||||
O formulário manual de criação/edição de documento fiscal (`document_form.html`) SHALL substituir o campo de data por dois seletores — Mês (Jan a Dez) e Ano (2026 a 2030) — para definir a competência do documento.
|
||||
|
||||
#### Scenario: Criação manual de documento
|
||||
- **WHEN** o usuário cria um novo documento fiscal manualmente
|
||||
- **THEN** o formulário exige a seleção de Mês e Ano em vez de uma data completa
|
||||
|
||||
#### Scenario: Edição de documento existente
|
||||
- **WHEN** o usuário edita um documento fiscal existente
|
||||
- **THEN** os seletores de Mês e Ano vêm pré-preenchidos com a competência atual do documento, podendo ser alterados
|
||||
|
||||
### Requirement: Exibição de Competência no Dashboard e Listagens
|
||||
O Dashboard e a listagem de documentos (`/documents`) SHALL exibir a competência de cada documento no formato "Mês abreviado/Ano com 2 dígitos" (ex. "Jun/26", "Jul/26") no lugar da antiga coluna "Data".
|
||||
|
||||
#### Scenario: Tabela "Últimos documentos" no Dashboard
|
||||
- **WHEN** o usuário visualiza o Dashboard
|
||||
- **THEN** cada linha da tabela "Últimos documentos" exibe a competência (mês/ano) do documento, em vez da data completa
|
||||
|
||||
#### Scenario: Coluna na listagem de documentos
|
||||
- **WHEN** o usuário visualiza a listagem `/documents`
|
||||
- **THEN** a coluna antes chamada "Data" exibe a competência (mês/ano) de cada documento, e continua ordenável
|
||||
|
||||
### Requirement: Filtro por Competência na Listagem de Documentos
|
||||
A listagem `/documents` SHALL permitir filtrar documentos por intervalo de competência (Mês/Ano inicial e Mês/Ano final), substituindo o filtro anterior por intervalo de datas.
|
||||
|
||||
#### Scenario: Filtro por intervalo de competência
|
||||
- **WHEN** o usuário seleciona uma competência inicial (ex. Jan/2026) e uma competência final (ex. Jun/2026) nos filtros da listagem
|
||||
- **THEN** apenas documentos cuja competência esteja dentro desse intervalo (inclusive) são exibidos
|
||||
|
||||
#### Scenario: Ordenação por competência
|
||||
- **WHEN** o usuário ordena a listagem pela coluna de competência
|
||||
- **THEN** os documentos são ordenados por ano e mês (crescente ou decrescente conforme selecionado)
|
||||
|
||||
### Requirement: Agregação Mensal do Dashboard por Competência Nativa
|
||||
O gráfico "Gastos por mês" do Dashboard SHALL agrupar os totais diretamente pelas colunas `ano`/`mes` de `fiscal_documents`, em vez de derivar o mês a partir de uma string de data.
|
||||
|
||||
#### Scenario: Totais mensais agrupados por competência
|
||||
- **WHEN** o Dashboard calcula os totais mensais para exibição
|
||||
- **THEN** o agrupamento é feito pelas colunas `ano` e `mes`, e o rótulo exibido (ex. "Jun/26") é formatado a partir desses valores nativos
|
||||
|
||||
### Requirement: Migração de Dados Existentes para Competência
|
||||
Ao atualizar para esta mudança, o sistema SHALL migrar automaticamente todos os registros existentes de `fiscal_documents` e `detected_documents`, preenchendo `mes` e `ano` a partir da data de compra anteriormente armazenada, antes de remover o campo de data.
|
||||
|
||||
#### Scenario: Migração automática na inicialização
|
||||
- **WHEN** a aplicação inicializa contra um banco de dados que ainda contém o campo de data completa
|
||||
- **THEN** o sistema preenche `mes` e `ano` de cada registro a partir da data existente e remove o campo de data, sem exigir intervenção manual do usuário
|
||||
|
||||
#### Scenario: Migração já aplicada não é repetida
|
||||
- **WHEN** a aplicação inicializa contra um banco de dados que já foi migrado (campo de data já removido)
|
||||
- **THEN** o sistema não tenta migrar novamente e opera normalmente com `mes`/`ano`
|
||||
@@ -0,0 +1,65 @@
|
||||
## 1. Migração de Schema (Banco de Dados)
|
||||
|
||||
- [x] 1.1 Em `app/database.py`, implementar rebuild de `fiscal_documents` (tabela `_new` com `mes INTEGER NOT NULL`, `ano INTEGER NOT NULL`, demais colunas iguais, sem `purchase_date`), copiando dados existentes com `mes = CAST(substr(purchase_date,6,2) AS INTEGER)` e `ano = CAST(substr(purchase_date,1,4) AS INTEGER)`, dentro de uma transação, guardado por checagem `PRAGMA table_info` (só roda se `purchase_date` ainda existir).
|
||||
- [x] 1.2 Repetir o mesmo rebuild para `detected_documents`, com `mes INTEGER` e `ano INTEGER` nullable (mantendo o mesmo comportamento opcional que `purchase_date` tinha).
|
||||
- [x] 1.3 Substituir o índice `idx_fiscal_date` por `idx_fiscal_competencia ON fiscal_documents(ano, mes)`.
|
||||
- [x] 1.4 Testar a migração rodando `init_db()` contra uma cópia do banco atual (com dados de `purchase_date` já existentes) e validar que `mes`/`ano` foram preenchidos corretamente e que rodar `init_db()` de novo não falha nem duplica/recria o rebuild.
|
||||
|
||||
## 2. Normalização de Competência (`app/dates.py`)
|
||||
|
||||
- [x] 2.1 Promover a lista `_MESES` (hoje em `dashboard_routes.py`) para `app/dates.py` como fonte única de abreviações de mês.
|
||||
- [x] 2.2 Implementar `resolve_competencia(date_raw, reference=None) -> tuple[int, int]`, substituindo `resolve_purchase_date`, com fallback para mês/ano corrente quando o valor for inválido/ausente.
|
||||
- [x] 2.3 Implementar `format_competencia(mes: int, ano: int) -> str` no formato `"Jun/26"` (mês abreviado capitalizado + ano com 2 dígitos), substituindo `format_br_date`, e atualizar o filtro Jinja registrado em `app/templating.py` (`brdate` → filtro de competência).
|
||||
- [x] 2.4 Ajustar `_is_plausible_purchase_date` (ou equivalente) para validar plausibilidade em termos de mês/ano (ex. não mais que alguns meses no futuro, ano não muito no passado), em vez de dias.
|
||||
|
||||
## 3. Extração (IA e OCR)
|
||||
|
||||
- [x] 3.1 Confirmar que `app/ai_extraction.py` e `lernotafiscal/extraction.py` continuam extraindo a data completa do documento sem alteração de prompt/regex (Decisão de design: descarte do dia acontece na normalização, não na extração).
|
||||
- [x] 3.2 Renomear/ajustar `RawExtraction.purchase_date_raw` e `DetectedDocumentCandidate.purchase_date` apenas na camada de repasse (`app/ingestion.py:_candidate_to_raw`), garantindo que o valor bruto siga até `resolve_competencia` sem mudança de comportamento de extração.
|
||||
|
||||
## 4. Persistência e Consultas (`app/database.py`)
|
||||
|
||||
- [x] 4.1 Atualizar `insert_detected` e `update_staged` para gravar `mes`/`ano` (via `resolve_competencia`) em vez de `purchase_date`.
|
||||
- [x] 4.2 Atualizar `confirm_batch` para exigir `mes`/`ano` válidos (em vez de `purchase_date` truthy) antes de promover um documento de staging para `fiscal_documents`.
|
||||
- [x] 4.3 Atualizar `create_fiscal` e `update_fiscal` para receber `mes`/`ano` como parâmetros obrigatórios em vez de `purchase_date`.
|
||||
- [x] 4.4 Atualizar `_fiscal_where` para filtrar por intervalo de competência usando a expressão `(ano * 12 + mes) BETWEEN ? AND ?`, substituindo o filtro por `purchase_date >= / <=`.
|
||||
- [x] 4.5 Atualizar `FISCAL_SORT_COLUMNS` para expor ordenação por `(ano, mes)` no lugar de `purchase_date`.
|
||||
- [x] 4.6 Atualizar `monthly_totals` para agrupar diretamente por `ano, mes` (`GROUP BY ano, mes ORDER BY ano, mes`), eliminando `substr(purchase_date, 1, 7)`.
|
||||
- [x] 4.7 Atualizar `category_totals` para filtrar por competência usando a mesma expressão `(ano * 12 + mes)`.
|
||||
|
||||
## 5. Fluxo de Upload e Revisão (Staging)
|
||||
|
||||
- [x] 5.1 Em `app/routes/upload_routes.py`, atualizar o handler de upload (`POST /upload`) para gravar `mes`/`ano` (via `resolve_competencia`) em vez de `purchase_date` ao inserir em `detected_documents`.
|
||||
- [x] 5.2 Atualizar `app/templates/staging.html`: substituir o `<input type="date" name="purchase_date">` por dois `<select>` — Mês (Jan-Dez) e Ano (2026-2030) — pré-selecionados com o valor detectado quando disponível.
|
||||
- [x] 5.3 Atualizar o handler `POST /import/{batch_id}/update/{detected_id}` para receber e persistir `mes`/`ano` do formulário de revisão.
|
||||
- [x] 5.4 Garantir que o cálculo de `summary["pendentes"]` (usado para bloquear o botão de confirmação do lote) passe a considerar `mes`/`ano` ausentes/inválidos como pendência, em vez de `purchase_date`.
|
||||
|
||||
## 6. Cadastro Manual de Documento
|
||||
|
||||
- [x] 6.1 Atualizar `app/templates/document_form.html`: substituir o `<input type="date" name="purchase_date">` pelos mesmos dois `<select>` de Mês/Ano usados na revisão, pré-preenchidos na edição.
|
||||
- [x] 6.2 Atualizar os handlers de criação/edição em `app/routes/documents_routes.py` (`/documents/new`, `/documents/{id}/edit`) para ler `mes`/`ano` do formulário e repassar a `create_fiscal`/`update_fiscal`.
|
||||
|
||||
## 7. Listagem e Filtros (`/documents`)
|
||||
|
||||
- [x] 7.1 Atualizar `app/templates/documents_list.html`: substituir os filtros `<input type="date" name="start">`/`name="end"` por dois pares de `<select>` Mês/Ano ("De" e "Até").
|
||||
- [x] 7.2 Atualizar o handler `GET /documents` em `app/routes/documents_routes.py` para ler os novos parâmetros de filtro de competência e repassá-los a `list_fiscal`/`fiscal_summary`.
|
||||
- [x] 7.3 Atualizar a coluna "Data" em `documents_list.html` para exibir a competência formatada (`format_competencia`) e manter a ordenação por competência (`sort_url` apontando para `ano`/`mes`).
|
||||
|
||||
## 8. Dashboard
|
||||
|
||||
- [x] 8.1 Atualizar `app/routes/dashboard_routes.py`: `_mes_label` passa a usar `format_competencia`/`_MESES` centralizados em `app/dates.py`, consumindo `ano`/`mes` nativos vindos de `monthly_totals`.
|
||||
- [x] 8.2 Atualizar `app/templates/dashboard.html`: tabela "Últimos documentos" exibe a competência (`format_competencia`) no lugar de `d.purchase_date | brdate`.
|
||||
|
||||
## 9. Scripts e Testes
|
||||
|
||||
- [x] 9.1 Atualizar `scripts/migrate_notas.py` para gravar `mes`/`ano` em vez de `purchase_date` ao chamar `create_fiscal`.
|
||||
- [x] 9.2 Atualizar `tests/test_app.py` (usos de `resolve_purchase_date`, `create_fiscal`, `update_fiscal` com `purchase_date=...`) para o novo par de campos.
|
||||
- [x] 9.3 Atualizar `tests/test_ingestion.py` (`docs[0].purchase_date`) para verificar `mes`/`ano` em vez de data completa.
|
||||
- [x] 9.4 Atualizar `tests/test_categorization.py` (`db.create_fiscal(..., purchase_date=...)`) para o novo schema.
|
||||
- [x] 9.5 Rodar a suíte de testes completa e corrigir quaisquer quebras remanescentes relacionadas a `purchase_date`.
|
||||
|
||||
## 10. Validação Manual
|
||||
|
||||
- [x] 10.1 Rodar a aplicação localmente, fazer upload de um documento de teste, confirmar que a tela de revisão exige seleção de Mês/Ano antes de habilitar a confirmação do lote, e validar que o documento aparece corretamente no Dashboard e em `/documents` após confirmado.
|
||||
- [x] 10.2 Validar cadastro manual (criar/editar) de um documento fiscal usando os novos seletores de Mês/Ano.
|
||||
- [x] 10.3 Validar o filtro por intervalo de competência em `/documents` e a ordenação pela coluna de competência.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-24
|
||||
@@ -0,0 +1,81 @@
|
||||
## Context
|
||||
|
||||
LerNotaFiscal is a FastAPI app that ingests Brazilian invoices (PDF/image), extracts `supplier_name`, `purchase_date`, `total_paid` via `extract_with_ai` (`app/ai_extraction.py`, OpenAI `chat.completions.create` with vision, JSON mode), with an OCR/heuristic fallback in `ingestion.py` when AI is disabled or fails. Persistence is raw `sqlite3` (`app/database.py`): no ORM, no migrations framework — the `SCHEMA` string is executed via `executescript()` on every connection open, so new tables/columns must be added additively (`CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ... ADD COLUMN` guarded by a `PRAGMA table_info` check, since SQLite's `ADD COLUMN IF NOT EXISTS` isn't available in older syntax). The Dashboard (`app/routes/dashboard_routes.py` + `app/templates/dashboard.html`) currently shows KPIs, a monthly bar chart, and a top-8 supplier bar chart, with no filter controls; filtering by supplier/date exists only on the documents list page (`documents_routes.py`, via `list_fiscal(conn, start, end, supplier)`). There is currently no rate-limiting, retry, or caching layer anywhere in the codebase — AI safety today is a bare `try/except` around the extraction call.
|
||||
|
||||
This change adds category classification to fiscal documents, with AI as the primary classifier and a user-maintained keyword table as deterministic fallback, plus safety limits so the new AI call path cannot loop or blow up token spend.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Persist a category per fiscal document, derived automatically (AI first, keyword fallback second, reserved "Não Encontrado" category otherwise — never left unclassified for documents that go through the flow).
|
||||
- Let the categorization result be visible and filterable on the Dashboard.
|
||||
- Guarantee the AI categorization call can run at most once per document per ingestion, with a supplier-level cache and a per-batch call ceiling, so it can never loop or run away on cost.
|
||||
- Keep the `categoria` table simple and editable (id, categoria, palavra_chave) so non-technical users can extend keyword matching without code changes.
|
||||
|
||||
**Non-Goals:**
|
||||
- No multi-category-per-document (one category per fiscal document in this change).
|
||||
- No retraining/fine-tuning of the AI model; categorization uses the existing OpenAI client with a prompt, not a separate ML pipeline.
|
||||
- No UI for bulk re-categorization of historical documents (existing documents predating this change simply show "Sem categoria" (`categoria_id` still `NULL`) until reprocessed; a manual "re-run categorization" action is out of scope unless trivial to add in tasks).
|
||||
- No category hierarchy/subcategories.
|
||||
|
||||
## Decisions
|
||||
|
||||
**1. Categorization is a separate step after extraction, not baked into `extract_with_ai`'s prompt.**
|
||||
Rationale: `extract_with_ai` already has a fixed JSON schema for fornecedor/data/valor and is also exercised by the OCR-fallback path (where there is no AI call at all). Categorization needs its own safety limits (cache, per-batch ceiling) that are orthogonal to extraction, so it's cleaner as a new module `app/categorization.py` with a function like `categorize_supplier(supplier_name: str, conn, batch_id) -> int`, called from `ingestion.py` right before/at the same time `fiscal_documents` is created. The function always returns a valid `categoria.id` — a real category, or `1` ("Não Encontrado") when AI and keyword matching both fail — never `None`. Alternative considered: extend the extraction prompt to also return a category — rejected because it would apply AI categorization even when the caller only wants OCR fallback, and would entangle unrelated retry/cache logic with extraction.
|
||||
|
||||
**2. `categoria_id` lives on `fiscal_documents` (nullable FK), not a separate join table.**
|
||||
Rationale: one category per document is sufficient (see Non-Goals); a nullable integer FK matches the existing schema style (`INTEGER PRIMARY KEY AUTOINCREMENT`, snake_case). Added via `ALTER TABLE fiscal_documents ADD COLUMN categoria_id INTEGER REFERENCES categoria(id)`, guarded by a `PRAGMA table_info(fiscal_documents)` check before running the ALTER, consistent with the additive-schema pattern already used in `database.py`. `detected_documents` is left unchanged since categorization only needs to run once a document is confirmed into `fiscal_documents`. The column stays nullable at the schema level purely so pre-existing rows (created before this migration) don't need a backfill — the categorization flow itself never writes `NULL`; it always writes either a real category id or the reserved `1` (see Decision 3).
|
||||
|
||||
**3. Reserved row `categoria.id = 1` = "Não Encontrado", auto-ensured, undeletable.**
|
||||
Rationale: the user will populate the `categoria` table with their own categories/keywords, but the categorization flow needs a guaranteed, stable target to write to when AI and keyword matching both fail — it cannot depend on the user having remembered to seed anything. So the schema initialization step (the same `executescript`/setup path that creates the `categoria` table) also runs an idempotent `INSERT OR IGNORE INTO categoria (id, categoria, palavra_chave) VALUES (1, 'Não Encontrado', '')`, guaranteeing the row exists on first run without ever overwriting it if the user has since edited its `categoria`/`palavra_chave` values. `delete_categoria` rejects deletion when `categoria_id == 1` (checked before running the delete). Alternative considered: have the app raise/500 if row 1 is missing and require the user to seed it manually first — rejected as too fragile; a one-line idempotent insert removes an entire class of "why isn't categorization working" support issues at negligible cost.
|
||||
|
||||
**4. AI categorization is a lightweight, separate OpenAI call scoped to just the supplier name.**
|
||||
Rationale: sending only `supplier_name` (plus the list of known category names as allowed options, sourced from distinct `categoria.categoria` values already in the table) keeps the prompt tiny and cheap compared to the vision-based extraction call, and lets us validate the AI's answer against a closed set of categories. If the AI returns a value outside that set (or the call fails), it's treated as "no usable category" and the flow falls to keyword fallback. Alternative considered: let the AI invent free-text categories — rejected, since ungoverned category proliferation would break the keyword table concept and the Dashboard grouping.
|
||||
|
||||
**5. Keyword fallback matching is case-insensitive substring match of `palavra_chave` in `supplier_name`, first match wins, excluding the reserved row.**
|
||||
Rationale: simplest possible rule that a non-technical user can reason about when populating the `categoria` table; matches the existing `LIKE`-based supplier filter style already in `list_fiscal`. Row order (`id ASC`, `id != 1`) determines precedence when multiple keywords could match — documented so admins can order entries if needed. **Important**: `find_categoria_by_keyword` must exclude `id = 1` from its search. Since the reserved row's `palavra_chave` is normally an empty string, and an empty string is a substring of every value, including it in the ordered scan would make it match first (lowest id) on every call and defeat all real keyword matching. `id = 1` is only ever reached as the final, explicit fallback in `categorize_supplier` when `find_categoria_by_keyword` (over `id != 1`) returns no match — never as a keyword-table hit itself. Alternative considered: token-based/fuzzy matching — deferred as unnecessary complexity for v1.
|
||||
|
||||
**6. Safety limits are implemented as a small in-process module, not an external rate-limiter.**
|
||||
Rationale: the app has no existing rate-limiting infrastructure and volume is modest (single-user/small-team invoice processing), so a simple in-memory cache dict (`supplier_name -> categoria_id`) plus a per-import-batch counter (reset at the start of each `import_batches` row) is sufficient and avoids new dependencies. Concretely:
|
||||
- **No retry**: the categorization call wraps a single `try/except`, mirroring `extract_with_ai`'s existing pattern — on any exception, treat as "no category from AI" and continue.
|
||||
- **Supplier cache**: keyed by normalized (`strip().upper()`) supplier name, populated the first time a supplier is categorized (whether by AI, keyword, or the `1`/"Não Encontrado" fallback) within the process lifetime; on cache hit, skip the AI call entirely and reuse the cached `categoria_id`. This is an in-memory dict for this change (module-level), acceptable since it degrades gracefully (worst case: re-categorize on process restart, still bounded by the per-batch limit).
|
||||
- **Per-batch ceiling**: a configurable `CATEGORIZATION_MAX_AI_CALLS_PER_BATCH` (default e.g. 50) in `app/config.py`; a counter tied to the current `import_batches.id` is incremented per AI call and checked before each call; once reached, remaining documents in that batch use keyword fallback (then `1`/"Não Encontrado" if no keyword matches) with no further AI calls.
|
||||
- **Logging**: each skip (cache hit or limit reached) logs at INFO/DEBUG with supplier name and reason, using the existing logging setup.
|
||||
Alternative considered: persisting the cache in a DB table — deferred; in-memory is enough to satisfy "no infinite loop / no runaway cost" and keeps the change additive and low-risk.
|
||||
|
||||
**7. Dashboard category breakdown reuses the existing chart/query pattern (`supplier_totals`-style function), plus a new `category` query param alongside `start`/`end`.**
|
||||
Rationale: `dashboard_routes.py` already computes `monthly_totals` and `supplier_totals` from `fiscal_documents`; a new `category_totals(conn, start, end)` follows the same shape, and threading an optional `category` filter through the existing query functions (`list_fiscal`, `monthly_totals`, `supplier_totals`, new `category_totals`) is consistent with how `supplier` filtering already works. The category filter control is added to the Dashboard template, populated from `list_categorias` (which includes "Não Encontrado") plus a separate "Sem categoria" option for any legacy `categoria_id IS NULL` rows.
|
||||
|
||||
**8. Categoria CRUD admin UI mirrors the existing "Documentos" list/form pattern exactly — new files, same conventions, no new UI framework.**
|
||||
Rationale: the project already has an established, working pattern for list + create/edit + delete pages (`documents_list.html` + `document_form.html`, driven by `documents_routes.py`), all server-rendered Jinja2, plain HTML forms (POST-only, no JS/AJAX), CSRF via a hidden `csrf_token` field checked with `auth.check_csrf`, and a shared stylesheet (`app/static/styles.css`, classes like `card`, `table`, `table-scroll`, `page-head`, `btn btn-primary/ghost/sm`, `linkbtn danger`, `field`, `filters`). Reusing it exactly (rather than introducing a component library, modal dialogs, or AJAX) keeps the new admin screens visually and behaviorally indistinguishable from the rest of the app. Concretely:
|
||||
- New router `app/routes/categoria_routes.py` (`router = APIRouter()`), imported and registered in `app/main.py` alongside the existing routers, following the same `GET/POST` route-pair convention used for documents:
|
||||
- `GET /categorias` — list (reuses `card`/`table`/`table-scroll` markup)
|
||||
- `GET /categorias/new`, `POST /categorias/new` — create form + handler
|
||||
- `GET /categorias/{categoria_id}/edit`, `POST /categorias/{categoria_id}/edit` — edit form + handler
|
||||
- `POST /categorias/{categoria_id}/delete` — delete (inline per-row form with `onsubmit="return confirm(...)"` and hidden `csrf_token`, exactly like the documents list's delete action)
|
||||
- New templates `app/templates/categorias_list.html` and `app/templates/categoria_form.html`, both `{% extends "base.html" %}`, the form template reused for both create and edit via a `mode` variable (`"new"`/`"edit"`) exactly as `document_form.html` does.
|
||||
- `app/database.py` gains `create_categoria(conn, categoria, palavra_chave)`, `get_categoria(conn, categoria_id)`, `update_categoria(conn, categoria_id, categoria, palavra_chave)`, `delete_categoria(conn, categoria_id)`, alongside the already-planned `list_categorias`/`find_categoria_by_keyword`, matching the naming style of `create_fiscal`/`update_fiscal`.
|
||||
- A new "Categorias" link is added to the nav bar in `base.html` (`<nav class="nav">`), alongside "Dashboard"/"Documentos", using the same active-link `request.url.path.startswith(...)` pattern.
|
||||
- `delete_categoria` first checks `categoria_id == 1` and rejects the delete (flash error, no DB change) per Decision 3's reserved-row protection. Otherwise, deleting a `categoria` that is referenced by existing `fiscal_documents.categoria_id` must not orphan those rows or raise a foreign-key error: `delete_categoria` explicitly runs `UPDATE fiscal_documents SET categoria_id = 1 WHERE categoria_id = ?` (reassigning affected documents to "Não Encontrado") before deleting the `categoria` row, in the same transaction, rather than relying on `ON DELETE SET NULL` — SQLite foreign key enforcement (`PRAGMA foreign_keys`) is off by default and this codebase does not currently enable it, so the app must enforce this itself.
|
||||
Alternative considered: leave orphaned documents' `categoria_id` as `NULL` on delete instead of reassigning to `1` — rejected for consistency: `1` ("Não Encontrado") is now the single canonical "not properly classified" bucket for anything the categorization flow processes or re-processes, while `NULL` is reserved strictly for legacy pre-migration rows never touched by this flow.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Risk]** In-memory supplier cache and per-batch counter are lost on process restart (e.g., app redeploy mid-batch) → could allow a few extra AI calls right after restart. **Mitigation**: the per-batch ceiling is intentionally conservative (default well below any reasonable per-import volume), and the ceiling still applies from the moment the process restarts, bounding worst-case cost; a persistent cache can be added later if needed.
|
||||
- **[Risk]** AI-suggested categories drifting from the closed set (typos, casing) could be silently rejected, causing over-reliance on keyword fallback. **Mitigation**: normalize AI output (trim/case-fold) before validating against known categories; log rejected AI suggestions so gaps in the `categoria` table are visible for the admin to fix.
|
||||
- **[Risk]** Existing fiscal documents (created before this change) will show "Sem categoria" (`categoria_id` `NULL`) until reprocessed, distinct from "Não Encontrado" (`categoria_id = 1`) used for documents the flow actively tried and failed to classify. **Mitigation**: acceptable for this change (Non-Goal: no bulk re-categorization); documented as an intentional distinction so it isn't mistaken for a bug; can be revisited if users need a backfill.
|
||||
- **[Risk]** If the operator edits the reserved row's `categoria`/`palavra_chave` values (e.g. changes `palavra_chave` to something non-empty), it could start matching real suppliers via the keyword fallback path, diluting its meaning as a pure "nothing matched" bucket. **Mitigation**: document that `id = 1` is reserved for "no match" semantics and its `palavra_chave` should normally stay empty; the app does not enforce this beyond the initial seed since the user is expected to manage the table's content.
|
||||
- **[Trade-off]** Choosing "first keyword match wins" instead of "most specific match wins" is simpler but could misclassify if keywords overlap (e.g., "MERCADO" matching both a generic and specific entry). Documented as a known limitation; admins should keep keywords reasonably distinct.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Add `categoria` table (with the idempotent `id = 1` "Não Encontrado" seed insert) and `fiscal_documents.categoria_id` column via additive `executescript`/`ALTER TABLE` in `app/database.py` (guarded by existence checks so it's safe to run against existing databases).
|
||||
2. Ship `app/categorization.py` with the AI-then-keyword-then-none flow and safety limits, wired into `ingestion.py` at document confirmation time.
|
||||
3. Update `dashboard_routes.py`/`dashboard.html` to add category totals + filter.
|
||||
4. Ship `app/routes/categoria_routes.py` + `categorias_list.html`/`categoria_form.html` for CRUD admin management of the `categoria` table, registered in `app/main.py` and linked from `base.html`'s nav.
|
||||
5. Deploy is a normal code + schema update; no data backfill required (existing rows simply have `categoria_id = NULL`).
|
||||
6. Rollback: revert code; the added column/table can remain harmless if rolled back (nullable, unused), or be dropped manually if desired.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should users be able to manually override/edit a document's category from the documents list UI in this change, or is that a follow-up? (Assumed follow-up unless trivial.)
|
||||
@@ -0,0 +1,37 @@
|
||||
## Why
|
||||
|
||||
Hoje o LerNotaFiscal extrai fornecedor, data e valor de cada nota fiscal, mas não classifica a despesa por categoria (ex.: Alimentação, Transporte, Saúde). Sem categoria, o usuário não consegue entender para onde o dinheiro está indo pelo Dashboard, apenas por fornecedor ou período. Precisamos categorizar automaticamente cada nota no momento da extração, com uma regra de negócio clara (AI primeiro, palavra-chave como fallback) e sem risco de gerar custo descontrolado de tokens de IA.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Criar tabela `categoria` (`id`, `categoria`, `palavra_chave`) para mapear palavras-chave a categorias, usada como fallback e administrável pelo usuário. O registro `id = 1` é reservado pelo sistema para a categoria "Não Encontrado" e é garantido automaticamente na inicialização do schema; o usuário é responsável por cadastrar as demais categorias/palavras-chave pela tela de CRUD.
|
||||
- Adicionar coluna `categoria_id` (FK para `categoria`) em `fiscal_documents` para persistir a classificação. Documentos processados pelo novo fluxo sempre recebem um `categoria_id` válido (nunca ficam nulos); apenas documentos já existentes antes desta mudança (não reprocessados) permanecem com `categoria_id` nulo ("Sem categoria").
|
||||
- Implementar fluxo de categorização automática:
|
||||
1. Ao processar uma nota, pedir à IA (mesma chamada de extração ou chamada dedicada) para sugerir a categoria a partir do nome do fornecedor.
|
||||
2. Se a IA não retornar uma categoria válida/reconhecida, buscar na tabela `categoria` por correspondência de `palavra_chave` no `supplier_name` e usar a `categoria` encontrada.
|
||||
3. Se a IA não conseguir categorizar **e** nenhuma `palavra_chave` corresponder, gravar `categoria_id = 1` ("Não Encontrado") sem bloquear o processamento.
|
||||
- Ajustar o Dashboard (`app/templates/dashboard.html` + `dashboard_routes.py`) para exibir gastos agrupados por categoria (gráfico/lista) e permitir filtrar os dados exibidos por categoria, na mesma linha dos filtros de fornecedor/período já existentes na tela de documentos.
|
||||
- Criar tela administrativa de CRUD (Create, Read, Update, Delete) para a tabela `categoria`, seguindo exatamente o mesmo layout/padrão já usado em "Documentos" (`documents_list.html` + `document_form.html`, mesmo `base.html`, mesmas classes CSS de `app/static/styles.css`, mesmo padrão de rotas `GET/POST /categorias`, `/categorias/new`, `/categorias/{id}/edit`, `POST /categorias/{id}/delete` com CSRF), para que o usuário possa cadastrar/editar/remover categorias e palavras-chave sem precisar de acesso direto ao banco.
|
||||
- Criar mecanismo de segurança contra loop infinito / consumo excessivo de tokens ao acionar a IA para categorização:
|
||||
- Limite de tentativas de chamada de IA por nota (ex.: no máximo 1 tentativa de categorização por nota, sem retry automático).
|
||||
- Cache/memória de categorização por fornecedor (se já categorizamos "Fornecedor X" antes, não chamar a IA de novo — reusar resultado ou usar a tabela `categoria`).
|
||||
- Circuit breaker/limite global (ex.: máximo de N chamadas de categorização por lote de importação ou por janela de tempo), com log e interrupção segura (fallback para palavra-chave) ao atingir o limite.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `expense-categorization`: Classificação automática de notas fiscais por categoria, com IA como fonte primária e a tabela `categoria` (palavra-chave) como fallback determinístico.
|
||||
- `categorization-safety-limits`: Limites e proteções (retries, cache, circuit breaker) para chamadas de IA usadas na categorização, evitando loops e consumo excessivo de tokens.
|
||||
|
||||
### Modified Capabilities
|
||||
- (nenhuma capability existente com spec.md hoje — projeto ainda não possui specs em `openspec/specs/`)
|
||||
|
||||
## Impact
|
||||
|
||||
- **Banco de dados** (`app/database.py`): nova tabela `categoria`; nova coluna `categoria_id` em `fiscal_documents` (e possivelmente `detected_documents`); nova migração aditiva no `SCHEMA`/`executescript`.
|
||||
- **IA** (`app/ai_extraction.py` ou novo módulo `app/categorization.py`): nova função/chamada para sugerir categoria a partir do `supplier_name`; ajuste no fluxo de `extract_with_ai` ou chamada adicional pós-extração.
|
||||
- **Ingestão** (`ingestion.py`): aplicar a lógica de categorização (IA → palavra-chave → `categoria_id = 1` "Não Encontrado") ao confirmar/criar `fiscal_documents`.
|
||||
- **Rotas/Dashboard** (`app/routes/dashboard_routes.py`, `app/templates/dashboard.html`): novo agrupamento e filtro por categoria.
|
||||
- **Nova rota de administração** (`app/routes/categoria_routes.py`, registrada em `app/main.py`; novos templates `app/templates/categorias_list.html` e `app/templates/categoria_form.html`, reaproveitando `base.html`): CRUD completo da tabela `categoria`, no mesmo padrão das rotas/telas de "Documentos".
|
||||
- **Rotas de documentos** (`app/routes/documents_routes.py`): opcionalmente permitir filtrar/editar categoria manualmente por nota.
|
||||
- **Configuração** (`app/config.py`): novos parâmetros para limites de segurança (ex.: `CATEGORIZATION_MAX_CALLS_PER_BATCH`, cache TTL).
|
||||
@@ -0,0 +1,40 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Single AI Attempt Per Document
|
||||
The system SHALL make at most one AI categorization call per fiscal document per ingestion attempt and SHALL NOT automatically retry a failed AI categorization call.
|
||||
|
||||
#### Scenario: AI categorization call fails
|
||||
- **WHEN** the AI categorization call for a document errors or times out
|
||||
- **THEN** the system does not retry the AI call for that document and proceeds directly to keyword fallback
|
||||
|
||||
### Requirement: Supplier Categorization Cache
|
||||
The system SHALL cache the category determined for a given supplier name (in-memory or persisted) and SHALL reuse the cached category for subsequent documents from the same supplier instead of calling the AI again.
|
||||
|
||||
#### Scenario: Second document from a known supplier
|
||||
- **WHEN** a fiscal document is processed for a supplier name that already has a cached category from a prior categorization
|
||||
- **THEN** the system uses the cached category and does not issue a new AI categorization call
|
||||
|
||||
#### Scenario: Cache miss for a new supplier
|
||||
- **WHEN** a fiscal document is processed for a supplier name with no cached category
|
||||
- **THEN** the system proceeds with the normal AI-then-keyword categorization flow and stores the result in the cache
|
||||
|
||||
### Requirement: Per-Batch AI Call Limit
|
||||
The system SHALL enforce a configurable maximum number of AI categorization calls within a single import batch (or time window). Once the limit is reached, remaining documents in that batch SHALL be categorized using only the keyword fallback, with no further AI calls, until the batch/window resets.
|
||||
|
||||
#### Scenario: Limit reached mid-batch
|
||||
- **WHEN** the number of AI categorization calls in the current batch reaches the configured maximum
|
||||
- **THEN** subsequent documents in the same batch skip the AI call and go directly to keyword fallback (or "Sem categoria" if no keyword matches)
|
||||
|
||||
### Requirement: Configurable Safety Limits
|
||||
The maximum AI calls per batch and cache behavior SHALL be configurable via application configuration/environment variables, not hardcoded in the categorization logic.
|
||||
|
||||
#### Scenario: Operator changes the configured limit
|
||||
- **WHEN** the configured maximum AI calls per batch is changed
|
||||
- **THEN** the categorization flow honors the new limit on the next run without code changes
|
||||
|
||||
### Requirement: Observability of Skipped AI Calls
|
||||
When an AI categorization call is skipped due to the cache or the per-batch limit, the system SHALL log the reason (cache hit or limit reached) so the behavior is observable and auditable.
|
||||
|
||||
#### Scenario: Skip logged
|
||||
- **WHEN** the system skips an AI categorization call because of a cache hit or because the batch limit was reached
|
||||
- **THEN** a log entry is recorded indicating which reason caused the skip and for which document/supplier
|
||||
@@ -0,0 +1,113 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Categoria Table Schema
|
||||
The system SHALL provide a `categoria` table with fields `id`, `categoria`, and `palavra_chave`, used to map keywords to category names as a fallback for automatic classification. The row with `id = 1` is reserved by the system for the "Não Encontrado" category and SHALL always exist; the system SHALL ensure this row is present (creating it if missing) during schema initialization, independent of any other categories the user registers.
|
||||
|
||||
#### Scenario: Table created on schema initialization
|
||||
- **WHEN** the application initializes or migrates the database schema
|
||||
- **THEN** the `categoria` table exists with columns `id`, `categoria`, `palavra_chave`, and a row with `id = 1` and `categoria = "Não Encontrado"` is present
|
||||
|
||||
#### Scenario: Reserved row already present
|
||||
- **WHEN** the application initializes and a `categoria` row with `id = 1` already exists (e.g., the user has customized its `categoria`/`palavra_chave` values)
|
||||
- **THEN** the system does not overwrite or duplicate that row
|
||||
|
||||
### Requirement: Automatic Categorization on Ingestion
|
||||
The system SHALL attempt to determine the category of a fiscal document automatically at the moment it is confirmed/created, without requiring manual user input.
|
||||
|
||||
#### Scenario: New fiscal document confirmed triggers categorization
|
||||
- **WHEN** a detected document is confirmed and a `fiscal_documents` row is created
|
||||
- **THEN** the system runs the categorization flow (AI, then keyword fallback) before finishing the request
|
||||
|
||||
### Requirement: AI-Based Categorization by Supplier Name
|
||||
The system SHALL first attempt to categorize a fiscal document by sending the supplier name to the AI service and requesting a category classification.
|
||||
|
||||
#### Scenario: AI returns a valid category
|
||||
- **WHEN** the AI service returns a recognized, non-empty category for the supplier name
|
||||
- **THEN** the system stores that category on the fiscal document and does not consult the `categoria` keyword table
|
||||
|
||||
#### Scenario: AI call fails or returns no usable category
|
||||
- **WHEN** the AI call raises an error, times out, or returns an empty/unrecognized value
|
||||
- **THEN** the system proceeds to keyword fallback categorization instead of failing the request
|
||||
|
||||
### Requirement: Keyword Fallback Categorization
|
||||
When AI categorization does not produce a valid category, the system SHALL search the `categoria` table for a `palavra_chave` that matches (case-insensitive, substring) the supplier name, and use the corresponding `categoria` value.
|
||||
|
||||
#### Scenario: Keyword match found
|
||||
- **WHEN** AI categorization did not yield a category and a `categoria.palavra_chave` is found as a substring of the supplier name (case-insensitive)
|
||||
- **THEN** the system assigns the matching `categoria.categoria` value to the fiscal document
|
||||
|
||||
#### Scenario: No keyword match found
|
||||
- **WHEN** AI categorization did not yield a category and no `palavra_chave` matches the supplier name
|
||||
- **THEN** the system assigns `categoria_id = 1` ("Não Encontrado") to the fiscal document instead of guessing
|
||||
|
||||
### Requirement: Fallback to Reserved "Não Encontrado" Category
|
||||
When neither AI nor keyword matching determines a category for a fiscal document being processed by the categorization flow, the system SHALL persist that document with `categoria_id = 1` (the reserved "Não Encontrado" category) and SHALL NOT block or fail document processing.
|
||||
|
||||
#### Scenario: No category determined by any method
|
||||
- **WHEN** AI categorization fails/is inconclusive and no keyword match exists for a document going through the categorization flow
|
||||
- **THEN** the fiscal document is saved successfully with `categoria_id = 1` and shown as "Não Encontrado" in the UI
|
||||
|
||||
#### Scenario: Legacy documents predating this change remain distinct from "Não Encontrado"
|
||||
- **WHEN** a `fiscal_documents` row was created before this change and has never been run through the categorization flow
|
||||
- **THEN** its `categoria_id` remains `NULL` and it is shown as "Sem categoria" (distinct from the explicit "Não Encontrado" outcome), until it is reprocessed
|
||||
|
||||
### Requirement: Dashboard Category Breakdown
|
||||
The Dashboard SHALL display expenses aggregated by category (e.g., totals per category) alongside existing period and supplier breakdowns.
|
||||
|
||||
#### Scenario: Dashboard shows category totals
|
||||
- **WHEN** a user opens the Dashboard
|
||||
- **THEN** the page displays total spend grouped by category, including the "Não Encontrado" group (`categoria_id = 1`) for documents the categorization flow could not classify, and a separate "Sem categoria" group for any legacy documents with `categoria_id` still `NULL`
|
||||
|
||||
### Requirement: Dashboard Category Filter
|
||||
The Dashboard SHALL allow the user to filter the displayed expenses by a single selected category.
|
||||
|
||||
#### Scenario: User filters by category
|
||||
- **WHEN** the user selects a category from the Dashboard's category filter
|
||||
- **THEN** all Dashboard figures (KPIs, charts, recent documents) update to reflect only fiscal documents in that category
|
||||
|
||||
#### Scenario: User clears the category filter
|
||||
- **WHEN** the user clears the selected category filter
|
||||
- **THEN** the Dashboard returns to showing all fiscal documents regardless of category
|
||||
|
||||
### Requirement: Categoria Administration List
|
||||
The system SHALL provide an authenticated admin page listing all rows of the `categoria` table (`id`, `categoria`, `palavra_chave`), following the same page layout, table markup, and CSS classes already used by the existing "Documentos" list page.
|
||||
|
||||
#### Scenario: User views the categoria list
|
||||
- **WHEN** an authenticated user navigates to the categorias admin page
|
||||
- **THEN** the system displays all `categoria` rows in a table matching the existing list-page layout (`card`/`table`/`table-scroll` styling), with actions to create, edit, and delete a row
|
||||
|
||||
### Requirement: Categoria Creation
|
||||
The system SHALL allow an authenticated user to create a new `categoria` row (`categoria`, `palavra_chave`) via a form that follows the same structure, validation, and CSRF protection as the existing document create/edit form.
|
||||
|
||||
#### Scenario: User creates a new categoria
|
||||
- **WHEN** an authenticated user submits the "new categoria" form with a non-empty `categoria` and `palavra_chave`
|
||||
- **THEN** the system inserts a new row into the `categoria` table and redirects to the categoria list showing the new entry
|
||||
|
||||
#### Scenario: User submits an invalid categoria form
|
||||
- **WHEN** an authenticated user submits the "new categoria" form with a missing `categoria` or `palavra_chave`
|
||||
- **THEN** the system re-displays the form with a validation error and does not create a row
|
||||
|
||||
### Requirement: Categoria Update
|
||||
The system SHALL allow an authenticated user to edit an existing `categoria` row's `categoria` and `palavra_chave` values, reusing the same form template pattern used for creation (single form, `mode` toggling between new/edit).
|
||||
|
||||
#### Scenario: User edits an existing categoria
|
||||
- **WHEN** an authenticated user submits the edit form for an existing `categoria` row with valid values
|
||||
- **THEN** the system updates that row and redirects to the categoria list reflecting the new values
|
||||
|
||||
### Requirement: Categoria Deletion
|
||||
The system SHALL allow an authenticated user to delete an existing `categoria` row (other than the reserved `id = 1` row) via a CSRF-protected POST action, following the same inline delete-form-with-confirmation pattern used on the existing "Documentos" list page.
|
||||
|
||||
#### Scenario: User deletes a categoria
|
||||
- **WHEN** an authenticated user confirms deletion of a `categoria` row with `id != 1`
|
||||
- **THEN** the system removes that row from the `categoria` table and redirects to the categoria list without it
|
||||
|
||||
#### Scenario: Deleting a categoria referenced by fiscal documents
|
||||
- **WHEN** an authenticated user deletes a `categoria` row that is currently referenced by one or more `fiscal_documents.categoria_id`
|
||||
- **THEN** the system completes the deletion and reassigns those fiscal documents' `categoria_id` to `1` ("Não Encontrado"), without errors or orphaned references
|
||||
|
||||
### Requirement: Reserved Categoria Cannot Be Deleted
|
||||
The system SHALL prevent deletion of the `categoria` row with `id = 1` ("Não Encontrado"), since it is the required fallback target for automatic categorization.
|
||||
|
||||
#### Scenario: User attempts to delete the reserved categoria
|
||||
- **WHEN** an authenticated user attempts to delete the `categoria` row with `id = 1`
|
||||
- **THEN** the system rejects the deletion, shows an error message, and the row remains unchanged
|
||||
@@ -0,0 +1,44 @@
|
||||
## 1. Schema: tabela `categoria` e coluna `categoria_id`
|
||||
|
||||
- [x] 1.1 Adicionar `categoria` (`id INTEGER PRIMARY KEY AUTOINCREMENT`, `categoria TEXT NOT NULL`, `palavra_chave TEXT NOT NULL`) ao `SCHEMA`/`executescript` em `app/database.py` (`CREATE TABLE IF NOT EXISTS`)
|
||||
- [x] 1.2 Adicionar coluna `categoria_id INTEGER REFERENCES categoria(id)` (nullable) em `fiscal_documents`, com checagem via `PRAGMA table_info(fiscal_documents)` antes do `ALTER TABLE` para não falhar em bancos já existentes
|
||||
- [x] 1.3 Adicionar insert idempotente `INSERT OR IGNORE INTO categoria (id, categoria, palavra_chave) VALUES (1, 'Não Encontrado', '')` no mesmo passo de inicialização do schema, garantindo que a linha reservada `id = 1` sempre exista sem nunca sobrescrever edições feitas pelo usuário
|
||||
- [x] 1.4 Adicionar helpers em `app/database.py`: `create_categoria(conn, categoria, palavra_chave)`, `get_categoria(conn, categoria_id)`, `list_categorias(conn)`, `update_categoria(conn, categoria_id, categoria, palavra_chave)`, `delete_categoria(conn, categoria_id)` (deve rejeitar `categoria_id == 1` sem alterar nada; caso contrário, fazer `UPDATE fiscal_documents SET categoria_id = 1 WHERE categoria_id = ?` antes de excluir a linha, na mesma transação), `find_categoria_by_keyword(conn, supplier_name)` (busca case-insensitive de `palavra_chave` como substring de `supplier_name`, **excluindo `id = 1`**, ordenado por `id ASC`, retorna a primeira correspondência — importante: `palavra_chave` vazia da linha reservada é substring de qualquer texto, então incluí-la quebraria o casamento de palavras-chave reais)
|
||||
|
||||
## 2. Módulo de categorização com limites de segurança
|
||||
|
||||
- [x] 2.1 Criar `app/categorization.py` com função `categorize_supplier(supplier_name: str, conn, batch_id) -> int` (sempre retorna um `categoria.id` válido, nunca `None`) implementando a ordem: cache em memória → limite por lote → IA → palavra-chave → `1` ("Não Encontrado")
|
||||
- [x] 2.2 Implementar cache em memória por fornecedor normalizado (`strip().upper()`), populado após qualquer categorização (IA, palavra-chave, ou `1` quando nada é encontrado), consultado antes de qualquer chamada de IA
|
||||
- [x] 2.3 Implementar contador de chamadas de IA por `import_batches.id`, comparado a `settings.CATEGORIZATION_MAX_AI_CALLS_PER_BATCH`; ao atingir o limite, pular direto para o fallback de palavra-chave (e depois `1` se nada corresponder)
|
||||
- [x] 2.4 Implementar chamada de IA dedicada (OpenAI `chat.completions.create`, prompt curto com `supplier_name` + lista de categorias já existentes na tabela `categoria`), validando que a resposta pertence ao conjunto conhecido; qualquer exceção ou valor fora do conjunto é tratado como "sem categoria da IA" (sem retry)
|
||||
- [x] 2.5 Adicionar `CATEGORIZATION_MAX_AI_CALLS_PER_BATCH` (default configurável) em `app/config.py`
|
||||
- [x] 2.6 Adicionar logs (INFO/DEBUG) quando uma chamada de IA é pulada por cache hit ou por limite de lote atingido, incluindo fornecedor e motivo
|
||||
|
||||
## 3. Integração no fluxo de ingestão
|
||||
|
||||
- [x] 3.1 Chamar `categorize_supplier` no momento em que o `fiscal_documents` é criado/confirmado, persistindo o `categoria_id` retornado (sempre um valor válido, no mínimo `1`) — implementado em `database.py::confirm_batch` (não em `ingestion.py`: a criação/confirmação de `fiscal_documents` acontece em `database.py`, `ingestion.py` só extrai)
|
||||
- [x] 3.2 Garantir que falha na categorização (qualquer exceção não tratada dentro do módulo) não impede a criação do `fiscal_documents` — a nota deve ser salva com `categoria_id = 1` ("Não Encontrado") nesse caso
|
||||
|
||||
## 4. Dashboard: exibição e filtro por categoria
|
||||
|
||||
- [x] 4.1 Criar função `category_totals(conn, start, end)` em `app/database.py` (mesmo padrão de `monthly_totals`/`supplier_totals`), agrupando por `categoria.categoria` (via LEFT JOIN, incluindo a linha `id = 1` "Não Encontrado") e um grupo separado "Sem categoria" apenas para `categoria_id IS NULL` (documentos legados não reprocessados)
|
||||
- [x] 4.2 Adicionar parâmetro opcional `category` em `dashboard_routes.py`, propagando o filtro para `monthly_totals`, `supplier_totals`, `category_totals` e demais consultas da página
|
||||
- [x] 4.3 Atualizar `app/templates/dashboard.html` com um gráfico/lista de gastos por categoria e um controle de filtro (select) populado a partir de `list_categorias` (inclui "Não Encontrado") + opção "Sem categoria" para os registros legados
|
||||
- [x] 4.4 Validado (sem extensão de navegador disponível na sessão: verificado via requisições HTTP autenticadas + inspeção do HTML renderizado, e via chamadas diretas às funções de banco simulando um lote confirmado) — Dashboard exibe totais por categoria e o filtro reduz corretamente KPIs/gráficos/lista de documentos recentes
|
||||
|
||||
## 5. CRUD administrativo da tabela `categoria`
|
||||
|
||||
- [x] 5.1 Criar `app/routes/categoria_routes.py` com `router = APIRouter()` e as rotas `GET /categorias`, `GET/POST /categorias/new`, `GET/POST /categorias/{categoria_id}/edit`, `POST /categorias/{categoria_id}/delete`, seguindo exatamente o padrão de `documents_routes.py` (uso de `render()`, `flash()`, `auth.check_csrf`)
|
||||
- [x] 5.2 Registrar `categoria_routes` em `app/main.py` (import + `app.include_router(categoria_routes.router)`), no mesmo bloco onde os demais routers são registrados
|
||||
- [x] 5.3 Criar `app/templates/categorias_list.html` (`{% extends "base.html" %}`) reaproveitando o layout de `documents_list.html`: `.page-head` com botão "+ Nova", tabela (`card`/`table`/`table-scroll`) listando `id`, `categoria`, `palavra_chave`, e ações de editar/excluir por linha (form inline com `onsubmit="return confirm(...)"` e `csrf_token` oculto, igual ao padrão de exclusão de documentos); a linha `id = 1` ("Não Encontrado") mostra a ação de excluir desabilitada/oculta, já que a exclusão é sempre rejeitada
|
||||
- [x] 5.4 Criar `app/templates/categoria_form.html` (`{% extends "base.html" %}`) reaproveitando o layout de `document_form.html`, com variável `mode` (`"new"`/`"edit"`) controlando a `action` do form entre `/categorias/new` e `/categorias/{id}/edit`
|
||||
- [x] 5.5 Adicionar link "Categorias" na `<nav class="nav">` de `app/templates/base.html`, com a mesma lógica de classe `active` (`request.url.path.startswith('/categorias')`) usada pelos demais links
|
||||
- [x] 5.6 Validado (sem extensão de navegador disponível na sessão: driver via requisições HTTP autenticadas contra o servidor real, cookies de sessão + CSRF) o fluxo completo: criar, listar, editar e excluir uma categoria; confirmado que excluir a categoria `id = 1` é rejeitado, e que excluir uma categoria em uso reatribui os documentos relacionados para "Não Encontrado" sem erro
|
||||
|
||||
## 6. Testes e validação
|
||||
|
||||
- [x] 6.1 Testes unitários para `find_categoria_by_keyword` (match, no match, case-insensitive, múltiplos candidatos → primeiro por `id`, e confirmando que `id = 1` nunca é retornado por essa função mesmo com `palavra_chave` vazia)
|
||||
- [x] 6.2 Testes unitários para `categorize_supplier` cobrindo: cache hit (sem chamada de IA), limite de lote atingido (sem chamada de IA), IA retorna categoria válida, IA falha/retorna inválido → fallback por palavra-chave, nenhum método encontra categoria → retorna `1`
|
||||
- [x] 6.3 Teste de integração do fluxo de ingestão ponta a ponta: nota processada resulta em `fiscal_documents.categoria_id` correto nos cenários de IA, fallback por palavra-chave e "Não Encontrado" (`1`)
|
||||
- [x] 6.4 Testes unitários/integração para o CRUD de `categoria`: criar, ler, atualizar, excluir (incluindo excluir categoria referenciada por `fiscal_documents` → reatribuição para `1`, e tentar excluir `id = 1` → rejeitado)
|
||||
- [x] 6.5 Automatizado como equivalente ao teste manual (`test_repeated_suppliers_call_ai_once_each_and_respect_batch_limit` em `tests/test_categorization.py`, usando mock + `assertLogs`): lote com fornecedores repetidos confirma que a IA é chamada apenas uma vez por fornecedor distinto e que o limite por lote é respeitado
|
||||
@@ -0,0 +1,20 @@
|
||||
schema: spec-driven
|
||||
|
||||
# Project context (optional)
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
# Example:
|
||||
# rules:
|
||||
# proposal:
|
||||
# - Keep proposals under 500 words
|
||||
# - Always include a "Non-goals" section
|
||||
# tasks:
|
||||
# - Break tasks into chunks of max 2 hours
|
||||
@@ -0,0 +1,87 @@
|
||||
# document-competencia Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Define como o sistema representa e gerencia a competência (mês/ano) de um documento fiscal, substituindo a data completa de compra por dois campos inteiros (`mes`, `ano`) em toda a aplicação — persistência, extração, revisão pós-upload, cadastro manual, dashboard, listagens e migração de dados existentes.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Persistência de Competência (Mês/Ano)
|
||||
O sistema SHALL persistir a competência de um documento fiscal como dois campos inteiros, `mes` (1-12) e `ano` (ex. 2026), em vez de uma data completa de compra. Em `fiscal_documents`, `mes` e `ano` SHALL ser obrigatórios (não nulos). Em `detected_documents` (staging), `mes` e `ano` SHALL permanecer opcionais enquanto o documento não tiver sido revisado/confirmado.
|
||||
|
||||
#### Scenario: Documento confirmado exige competência
|
||||
- **WHEN** um documento em staging é promovido para `fiscal_documents` via confirmação de lote
|
||||
- **THEN** o registro criado possui `mes` e `ano` preenchidos com valores válidos (mes entre 1 e 12, ano numérico)
|
||||
|
||||
#### Scenario: Documento em staging pode estar sem competência
|
||||
- **WHEN** um documento acabou de ser detectado por OCR/IA e ainda não foi revisado
|
||||
- **THEN** `mes` e/ou `ano` podem estar nulos em `detected_documents` até que o usuário revise e preencha a competência
|
||||
|
||||
### Requirement: Extração Não Persiste Mais Data Completa
|
||||
O sistema SHALL deixar de persistir o dia da compra em qualquer tabela. A extração via IA ou OCR pode continuar identificando uma data completa no texto/imagem do documento, mas o dia SHALL ser descartado antes da persistência, mantendo-se apenas mês e ano.
|
||||
|
||||
#### Scenario: Data completa detectada no documento
|
||||
- **WHEN** a extração (IA ou OCR) identifica uma data completa (dia/mês/ano) no documento
|
||||
- **THEN** o sistema armazena apenas o mês e o ano correspondentes, descartando o dia
|
||||
|
||||
### Requirement: Confirmação Obrigatória de Competência Antes do Envio
|
||||
Na tela de revisão pós-upload, o sistema SHALL exigir que o usuário confirme explicitamente a Competência (Mês/Ano) de cada documento, através de dois campos de seleção — Mês (Jan a Dez) e Ano (2026 a 2030) — antes de permitir a confirmação do lote.
|
||||
|
||||
#### Scenario: Competência pré-selecionada a partir da extração
|
||||
- **WHEN** a extração identificou um mês/ano válido para o documento
|
||||
- **THEN** os seletores de Mês e Ano na tela de revisão vêm pré-selecionados com esses valores, permitindo correção manual pelo usuário
|
||||
|
||||
#### Scenario: Competência ausente bloqueia confirmação do lote
|
||||
- **WHEN** um ou mais documentos do lote não têm Mês e Ano selecionados
|
||||
- **THEN** o sistema não permite confirmar o lote até que a competência de todos os documentos pendentes seja preenchida
|
||||
|
||||
### Requirement: Seleção de Competência no Cadastro Manual
|
||||
O formulário manual de criação/edição de documento fiscal (`document_form.html`) SHALL substituir o campo de data por dois seletores — Mês (Jan a Dez) e Ano (2026 a 2030) — para definir a competência do documento.
|
||||
|
||||
#### Scenario: Criação manual de documento
|
||||
- **WHEN** o usuário cria um novo documento fiscal manualmente
|
||||
- **THEN** o formulário exige a seleção de Mês e Ano em vez de uma data completa
|
||||
|
||||
#### Scenario: Edição de documento existente
|
||||
- **WHEN** o usuário edita um documento fiscal existente
|
||||
- **THEN** os seletores de Mês e Ano vêm pré-preenchidos com a competência atual do documento, podendo ser alterados
|
||||
|
||||
### Requirement: Exibição de Competência no Dashboard e Listagens
|
||||
O Dashboard e a listagem de documentos (`/documents`) SHALL exibir a competência de cada documento no formato "Mês abreviado/Ano com 2 dígitos" (ex. "Jun/26", "Jul/26") no lugar da antiga coluna "Data".
|
||||
|
||||
#### Scenario: Tabela "Últimos documentos" no Dashboard
|
||||
- **WHEN** o usuário visualiza o Dashboard
|
||||
- **THEN** cada linha da tabela "Últimos documentos" exibe a competência (mês/ano) do documento, em vez da data completa
|
||||
|
||||
#### Scenario: Coluna na listagem de documentos
|
||||
- **WHEN** o usuário visualiza a listagem `/documents`
|
||||
- **THEN** a coluna antes chamada "Data" exibe a competência (mês/ano) de cada documento, e continua ordenável
|
||||
|
||||
### Requirement: Filtro por Competência na Listagem de Documentos
|
||||
A listagem `/documents` SHALL permitir filtrar documentos por intervalo de competência (Mês/Ano inicial e Mês/Ano final), substituindo o filtro anterior por intervalo de datas.
|
||||
|
||||
#### Scenario: Filtro por intervalo de competência
|
||||
- **WHEN** o usuário seleciona uma competência inicial (ex. Jan/2026) e uma competência final (ex. Jun/2026) nos filtros da listagem
|
||||
- **THEN** apenas documentos cuja competência esteja dentro desse intervalo (inclusive) são exibidos
|
||||
|
||||
#### Scenario: Ordenação por competência
|
||||
- **WHEN** o usuário ordena a listagem pela coluna de competência
|
||||
- **THEN** os documentos são ordenados por ano e mês (crescente ou decrescente conforme selecionado)
|
||||
|
||||
### Requirement: Agregação Mensal do Dashboard por Competência Nativa
|
||||
O gráfico "Gastos por mês" do Dashboard SHALL agrupar os totais diretamente pelas colunas `ano`/`mes` de `fiscal_documents`, em vez de derivar o mês a partir de uma string de data.
|
||||
|
||||
#### Scenario: Totais mensais agrupados por competência
|
||||
- **WHEN** o Dashboard calcula os totais mensais para exibição
|
||||
- **THEN** o agrupamento é feito pelas colunas `ano` e `mes`, e o rótulo exibido (ex. "Jun/26") é formatado a partir desses valores nativos
|
||||
|
||||
### Requirement: Migração de Dados Existentes para Competência
|
||||
Ao atualizar para esta mudança, o sistema SHALL migrar automaticamente todos os registros existentes de `fiscal_documents` e `detected_documents`, preenchendo `mes` e `ano` a partir da data de compra anteriormente armazenada, antes de remover o campo de data.
|
||||
|
||||
#### Scenario: Migração automática na inicialização
|
||||
- **WHEN** a aplicação inicializa contra um banco de dados que ainda contém o campo de data completa
|
||||
- **THEN** o sistema preenche `mes` e `ano` de cada registro a partir da data existente e remove o campo de data, sem exigir intervenção manual do usuário
|
||||
|
||||
#### Scenario: Migração já aplicada não é repetida
|
||||
- **WHEN** a aplicação inicializa contra um banco de dados que já foi migrado (campo de data já removido)
|
||||
- **THEN** o sistema não tenta migrar novamente e opera normalmente com `mes`/`ano`
|
||||
@@ -0,0 +1,15 @@
|
||||
# Web app
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
jinja2
|
||||
python-multipart
|
||||
itsdangerous
|
||||
bcrypt
|
||||
python-dotenv
|
||||
|
||||
# Extração
|
||||
openai
|
||||
pypdf
|
||||
PyMuPDF
|
||||
Pillow
|
||||
pytesseract
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migra as notas da skill (tabela `notas`) para `fiscal_documents` do app.
|
||||
|
||||
Idempotente: deduplica por (mes, ano, supplier_name, total_paid).
|
||||
|
||||
Uso:
|
||||
python scripts/migrate_notas.py [caminho_do_banco_origem]
|
||||
|
||||
Origem padrão: Skill/dados/lernotafiscal.db
|
||||
Destino: o banco do app (DB_PATH / data/app.sqlite3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# permite rodar de qualquer lugar
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app import database as db # noqa: E402
|
||||
from app.dates import resolve_competencia # noqa: E402
|
||||
|
||||
DEFAULT_SOURCE = Path(__file__).resolve().parent.parent / "Skill" / "dados" / "lernotafiscal.db"
|
||||
|
||||
|
||||
def read_notas(source: Path) -> list[tuple]:
|
||||
conn = sqlite3.connect(source)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT data_compra, fornecedor, valor_pago, arquivo_origem FROM notas"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [(r["data_compra"], r["fornecedor"], float(r["valor_pago"]), r["arquivo_origem"]) for r in rows]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
source = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_SOURCE
|
||||
if not source.exists():
|
||||
print(f"Banco de origem não encontrado: {source}")
|
||||
return 1
|
||||
|
||||
notas = read_notas(source)
|
||||
inserted = skipped = 0
|
||||
with db.session() as conn:
|
||||
for data_compra, fornecedor, valor, arquivo in notas:
|
||||
mes, ano = resolve_competencia(data_compra)
|
||||
exists = conn.execute(
|
||||
"""
|
||||
SELECT 1 FROM fiscal_documents
|
||||
WHERE mes = ? AND ano = ? AND supplier_name = ? AND ROUND(total_paid, 2) = ROUND(?, 2)
|
||||
LIMIT 1
|
||||
""",
|
||||
(mes, ano, fornecedor, valor),
|
||||
).fetchone()
|
||||
if exists:
|
||||
skipped += 1
|
||||
continue
|
||||
db.create_fiscal(
|
||||
conn,
|
||||
mes=mes,
|
||||
ano=ano,
|
||||
supplier_name=fornecedor,
|
||||
total_paid=valor,
|
||||
source_file_name=arquivo or "migrado",
|
||||
source_location="migrado",
|
||||
confidence="high",
|
||||
)
|
||||
inserted += 1
|
||||
totals = db.overall_totals(conn)
|
||||
|
||||
print(f"Origem: {source}")
|
||||
print(f"Inseridos: {inserted} · Ignorados (duplicados): {skipped}")
|
||||
print(f"Total em fiscal_documents: {totals['count']} documento(s) · R$ {totals['total']:.2f}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,29 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
cd /d C:\LerNotaFiscal
|
||||
|
||||
echo === SUBINDO SERVIDOR (porta 8000) ===
|
||||
|
||||
echo Procurando processo ocupando a porta 8000...
|
||||
set "PID="
|
||||
for /f "tokens=5" %%a in ('netstat -ano ^| findstr LISTENING ^| findstr :8000') do (
|
||||
set "PID=%%a"
|
||||
)
|
||||
|
||||
if defined PID (
|
||||
echo Processo encontrado ^(PID !PID!^). Finalizando...
|
||||
taskkill /F /PID !PID!
|
||||
if errorlevel 1 (
|
||||
echo Aviso: nao foi possivel finalizar o PID !PID!.
|
||||
) else (
|
||||
echo Processo finalizado com sucesso.
|
||||
)
|
||||
) else (
|
||||
echo Nenhum processo ocupando a porta 8000.
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Iniciando servidor...
|
||||
.venv\Scripts\python.exe app.py
|
||||
|
||||
pause
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for Lernotafiscal."""
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Testes do app: fallback de data, autenticação e CRUD de documentos."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DateFallbackTests(unittest.TestCase):
|
||||
def test_iso_date_is_kept(self):
|
||||
from app.dates import resolve_competencia
|
||||
|
||||
self.assertEqual(resolve_competencia("2026-06-09"), (6, 2026))
|
||||
|
||||
def test_br_date_is_normalized(self):
|
||||
from app.dates import resolve_competencia
|
||||
|
||||
self.assertEqual(resolve_competencia("09/06/2026"), (6, 2026))
|
||||
|
||||
def test_illegible_date_falls_back_to_current_month(self):
|
||||
from app.dates import resolve_competencia
|
||||
|
||||
ref = date(2026, 7, 24)
|
||||
self.assertEqual(resolve_competencia("ilegível", reference=ref), (7, 2026))
|
||||
self.assertEqual(resolve_competencia(None, reference=ref), (7, 2026))
|
||||
self.assertEqual(resolve_competencia("", reference=ref), (7, 2026))
|
||||
|
||||
def test_invalid_calendar_date_falls_back(self):
|
||||
from app.dates import resolve_competencia
|
||||
|
||||
ref = date(2026, 7, 24)
|
||||
self.assertEqual(resolve_competencia("2026-13-40", reference=ref), (7, 2026))
|
||||
|
||||
|
||||
class PasswordTests(unittest.TestCase):
|
||||
def test_hash_and_verify(self):
|
||||
from app import auth
|
||||
|
||||
h = auth.hash_password("segredo-forte")
|
||||
self.assertTrue(auth.verify_password("segredo-forte", h))
|
||||
self.assertFalse(auth.verify_password("errada", h))
|
||||
|
||||
|
||||
class CrudTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
os.environ["DB_PATH"] = str(Path(self._tmp.name) / "crud.sqlite3")
|
||||
# zera o cache de settings para pegar o DB_PATH novo
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.pop("DB_PATH", None)
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_create_update_delete_list(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
doc_id = db.create_fiscal(
|
||||
conn, mes=7, ano=2026, supplier_name="Padaria X", total_paid=10.0
|
||||
)
|
||||
self.assertEqual(db.overall_totals(conn)["count"], 1)
|
||||
|
||||
db.update_fiscal(
|
||||
conn, doc_id, mes=7, ano=2026, supplier_name="Padaria Y", total_paid=12.5
|
||||
)
|
||||
row = db.get_fiscal(conn, doc_id)
|
||||
self.assertEqual(row["supplier_name"], "Padaria Y")
|
||||
self.assertEqual(row["total_paid"], 12.5)
|
||||
|
||||
rows = db.list_fiscal(conn, supplier="Padaria")
|
||||
self.assertEqual(len(rows), 1)
|
||||
|
||||
db.delete_fiscal(conn, doc_id)
|
||||
self.assertEqual(db.overall_totals(conn)["count"], 0)
|
||||
|
||||
|
||||
class StagingGateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
os.environ["DB_PATH"] = str(Path(self._tmp.name) / "stage.sqlite3")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.pop("DB_PATH", None)
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _stage(self, conn, batch_id, upload_id, **over):
|
||||
base = dict(
|
||||
upload_id=upload_id, batch_id=batch_id, source_file_name="f.pdf",
|
||||
source_page=None, source_location="f.pdf", raw_text="",
|
||||
mes=7, ano=2026, supplier_name="Loja", total_paid=10.0,
|
||||
confidence="high", field_confidence={}, legible=True,
|
||||
uncertain_fields=[], extractor="local",
|
||||
)
|
||||
base.update(over)
|
||||
from app import database as db
|
||||
|
||||
return db.insert_detected(conn, **base)
|
||||
|
||||
def test_incomplete_row_counts_as_pending_and_is_not_imported(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
batch = db.create_batch(conn)
|
||||
up = db.insert_upload(conn, batch, "f.pdf", Path("f.pdf"), "application/pdf", 1)
|
||||
self._stage(conn, batch, up) # completo
|
||||
# legível mas SEM fornecedor -> incompleto, deve contar como pendente
|
||||
self._stage(conn, batch, up, supplier_name=None, legible=True)
|
||||
|
||||
summary = db.batch_summary(conn, batch)
|
||||
self.assertEqual(summary["count"], 2)
|
||||
self.assertEqual(summary["incompletos"], 1)
|
||||
self.assertEqual(summary["pendentes"], 1)
|
||||
|
||||
def test_missing_competencia_counts_as_pending_and_blocks_confirmation(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
batch = db.create_batch(conn)
|
||||
up = db.insert_upload(conn, batch, "f.pdf", Path("f.pdf"), "application/pdf", 1)
|
||||
self._stage(conn, batch, up) # completo
|
||||
# legível, fornecedor e valor ok, mas SEM competência -> pendente
|
||||
self._stage(conn, batch, up, mes=None, ano=None)
|
||||
|
||||
summary = db.batch_summary(conn, batch)
|
||||
self.assertEqual(summary["pendentes"], 1)
|
||||
|
||||
inserted = db.confirm_batch(conn, batch)
|
||||
self.assertEqual(inserted, 1)
|
||||
|
||||
def test_confirm_only_promotes_complete_rows(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
batch = db.create_batch(conn)
|
||||
up = db.insert_upload(conn, batch, "f.pdf", Path("f.pdf"), "application/pdf", 1)
|
||||
self._stage(conn, batch, up, supplier_name="Loja A", total_paid=10.0)
|
||||
self._stage(conn, batch, up, supplier_name="Loja B", total_paid=20.0)
|
||||
inserted = db.confirm_batch(conn, batch)
|
||||
self.assertEqual(inserted, 2)
|
||||
self.assertEqual(db.overall_totals(conn)["total"], 30.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,420 @@
|
||||
"""Testes de categorização: palavra-chave, categorize_supplier (cache/limite/IA) e CRUD de categoria."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class _DbTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
os.environ["DB_PATH"] = str(Path(self._tmp.name) / "cat.sqlite3")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from app import categorization
|
||||
|
||||
categorization.reset_cache()
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.pop("DB_PATH", None)
|
||||
os.environ.pop("OPENAI_API_KEY", None)
|
||||
os.environ.pop("CATEGORIZATION_MAX_AI_CALLS_PER_BATCH", None)
|
||||
os.environ.pop("ADMIN_USERNAME", None)
|
||||
os.environ.pop("ADMIN_PASSWORD", None)
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from app import categorization
|
||||
|
||||
categorization.reset_cache()
|
||||
self._tmp.cleanup()
|
||||
|
||||
|
||||
class FindCategoriaByKeywordTests(_DbTestCase):
|
||||
def test_match_found(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
db.create_categoria(conn, "Alimentação", "MERCADO")
|
||||
row = db.find_categoria_by_keyword(conn, "Supermercado Bom Preço")
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row["categoria"], "Alimentação")
|
||||
|
||||
def test_no_match(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
db.create_categoria(conn, "Alimentação", "MERCADO")
|
||||
row = db.find_categoria_by_keyword(conn, "Posto de Gasolina XYZ")
|
||||
self.assertIsNone(row)
|
||||
|
||||
def test_case_insensitive(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
db.create_categoria(conn, "Transporte", "posto")
|
||||
row = db.find_categoria_by_keyword(conn, "POSTO SHELL CENTRO")
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row["categoria"], "Transporte")
|
||||
|
||||
def test_multiple_candidates_first_by_id_wins(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
first_id = db.create_categoria(conn, "Geral", "MERCADO")
|
||||
db.create_categoria(conn, "Específico", "MERCADO CENTRAL")
|
||||
row = db.find_categoria_by_keyword(conn, "Mercado Central Ltda")
|
||||
self.assertEqual(row["id"], first_id)
|
||||
self.assertEqual(row["categoria"], "Geral")
|
||||
|
||||
def test_reserved_row_never_returned_even_with_empty_keyword(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
# id=1 "Não Encontrado" já existe com palavra_chave='' (substring de tudo).
|
||||
row = db.find_categoria_by_keyword(conn, "Qualquer Fornecedor")
|
||||
self.assertIsNone(row)
|
||||
self.assertEqual(db.get_categoria(conn, 1)["categoria"], "Não Encontrado")
|
||||
|
||||
|
||||
class CategorizeSupplierTests(_DbTestCase):
|
||||
def test_cache_hit_skips_ai(self):
|
||||
from app import database as db
|
||||
from app import categorization
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "test-key"
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
with db.session() as conn:
|
||||
cat_id = db.create_categoria(conn, "Alimentação", "MERCADO")
|
||||
batch_id = db.create_batch(conn)
|
||||
with patch("app.categorization._call_ai", return_value="Alimentação") as mocked:
|
||||
first = categorization.categorize_supplier("Padaria X", conn, batch_id)
|
||||
second = categorization.categorize_supplier("padaria x", conn, batch_id)
|
||||
self.assertEqual(first, cat_id)
|
||||
self.assertEqual(second, cat_id)
|
||||
mocked.assert_called_once()
|
||||
|
||||
def test_batch_limit_reached_skips_ai_and_uses_keyword_fallback(self):
|
||||
from app import database as db
|
||||
from app import categorization
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "test-key"
|
||||
os.environ["CATEGORIZATION_MAX_AI_CALLS_PER_BATCH"] = "1"
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
with db.session() as conn:
|
||||
cat_id = db.create_categoria(conn, "Alimentação", "PADARIA")
|
||||
batch_id = db.create_batch(conn)
|
||||
with patch("app.categorization._call_ai", return_value=None) as mocked:
|
||||
categorization.categorize_supplier("Fornecedor Um", conn, batch_id)
|
||||
self.assertEqual(mocked.call_count, 1)
|
||||
result = categorization.categorize_supplier("Padaria Dois", conn, batch_id)
|
||||
# 2º fornecedor: limite já atingido -> IA não é chamada, cai no fallback de palavra-chave
|
||||
self.assertEqual(mocked.call_count, 1)
|
||||
self.assertEqual(result, cat_id)
|
||||
|
||||
def test_ai_returns_valid_category(self):
|
||||
from app import database as db
|
||||
from app import categorization
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "test-key"
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
with db.session() as conn:
|
||||
cat_id = db.create_categoria(conn, "Saúde", "FARMACIA")
|
||||
batch_id = db.create_batch(conn)
|
||||
with patch("app.categorization._call_ai", return_value="Saúde"):
|
||||
result = categorization.categorize_supplier("Drogaria Central", conn, batch_id)
|
||||
self.assertEqual(result, cat_id)
|
||||
|
||||
def test_ai_fails_or_invalid_falls_back_to_keyword(self):
|
||||
from app import database as db
|
||||
from app import categorization
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "test-key"
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
with db.session() as conn:
|
||||
cat_id = db.create_categoria(conn, "Transporte", "POSTO")
|
||||
batch_id = db.create_batch(conn)
|
||||
with patch("app.categorization._call_ai", return_value=None):
|
||||
result = categorization.categorize_supplier("Posto Ipiranga", conn, batch_id)
|
||||
self.assertEqual(result, cat_id)
|
||||
|
||||
def test_no_method_finds_category_returns_reserved(self):
|
||||
from app import database as db
|
||||
from app import categorization
|
||||
|
||||
with db.session() as conn:
|
||||
batch_id = db.create_batch(conn)
|
||||
result = categorization.categorize_supplier("Fornecedor Desconhecido", conn, batch_id)
|
||||
self.assertEqual(result, db.RESERVED_CATEGORIA_ID)
|
||||
|
||||
def test_unexpected_internal_failure_never_propagates_and_falls_back_to_reserved(self):
|
||||
"""3.2: qualquer exceção não tratada dentro do módulo -> categoria_id=1,
|
||||
nunca propaga para o chamador (que criaria fiscal_documents)."""
|
||||
from app import database as db
|
||||
from app import categorization
|
||||
|
||||
with db.session() as conn:
|
||||
batch_id = db.create_batch(conn)
|
||||
with patch("app.database.list_categorias", side_effect=RuntimeError("db explodiu")):
|
||||
result = categorization.categorize_supplier("Fornecedor Y", conn, batch_id)
|
||||
self.assertEqual(result, db.RESERVED_CATEGORIA_ID)
|
||||
|
||||
def test_repeated_suppliers_call_ai_once_each_and_respect_batch_limit(self):
|
||||
"""Equivalente automatizado da validação manual 6.5: lote com fornecedores
|
||||
repetidos chama a IA no máximo uma vez por fornecedor distinto, e respeita
|
||||
o limite por lote (observável via logs)."""
|
||||
from app import database as db
|
||||
from app import categorization
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "test-key"
|
||||
os.environ["CATEGORIZATION_MAX_AI_CALLS_PER_BATCH"] = "2"
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
with db.session() as conn:
|
||||
db.create_categoria(conn, "Alimentação", "MERCADO")
|
||||
batch_id = db.create_batch(conn)
|
||||
suppliers = ["Fornecedor A", "Fornecedor A", "Fornecedor B", "Mercado C", "Fornecedor A"]
|
||||
with patch("app.categorization._call_ai", return_value=None) as mocked, \
|
||||
self.assertLogs("lernotafiscal.categorization", level="INFO") as logs:
|
||||
for name in suppliers:
|
||||
categorization.categorize_supplier(name, conn, batch_id)
|
||||
# 2 fornecedores distintos com IA tentada (A, B) antes do limite=2 ser atingido
|
||||
self.assertEqual(mocked.call_count, 2)
|
||||
self.assertTrue(any("limite" in msg for msg in logs.output))
|
||||
|
||||
|
||||
class CategoriaCrudTests(_DbTestCase):
|
||||
def test_create_get_list_update(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
cat_id = db.create_categoria(conn, "Lazer", "CINEMA")
|
||||
row = db.get_categoria(conn, cat_id)
|
||||
self.assertEqual(row["categoria"], "Lazer")
|
||||
|
||||
db.update_categoria(conn, cat_id, "Entretenimento", "CINEMA")
|
||||
row = db.get_categoria(conn, cat_id)
|
||||
self.assertEqual(row["categoria"], "Entretenimento")
|
||||
|
||||
rows = db.list_categorias(conn)
|
||||
self.assertTrue(any(r["id"] == cat_id for r in rows))
|
||||
# linha reservada sempre presente
|
||||
self.assertTrue(any(r["id"] == 1 for r in rows))
|
||||
|
||||
def test_delete_reassigns_referencing_documents_to_reserved(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
cat_id = db.create_categoria(conn, "Lazer", "CINEMA")
|
||||
doc_id = db.create_fiscal(conn, mes=7, ano=2026, supplier_name="Cinema X", total_paid=30.0)
|
||||
conn.execute("UPDATE fiscal_documents SET categoria_id = ? WHERE id = ?", (cat_id, doc_id))
|
||||
conn.commit()
|
||||
|
||||
deleted = db.delete_categoria(conn, cat_id)
|
||||
self.assertTrue(deleted)
|
||||
self.assertIsNone(db.get_categoria(conn, cat_id))
|
||||
self.assertEqual(db.get_fiscal(conn, doc_id)["categoria_id"], db.RESERVED_CATEGORIA_ID)
|
||||
|
||||
def test_delete_reserved_row_rejected(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
deleted = db.delete_categoria(conn, db.RESERVED_CATEGORIA_ID)
|
||||
self.assertFalse(deleted)
|
||||
self.assertIsNotNone(db.get_categoria(conn, db.RESERVED_CATEGORIA_ID))
|
||||
|
||||
def test_sem_categoria_group_and_filter_cover_legacy_null_rows(self):
|
||||
"""Cobre o grupo/filtro 'Sem categoria' (categoria_id IS NULL, documento
|
||||
legado nunca reprocessado), distinto da categoria reservada 'Não Encontrado'
|
||||
(categoria_id = 1, usada pelo fluxo de categorização ativo)."""
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
# Legado: create_fiscal nunca seta categoria_id -> permanece NULL.
|
||||
legacy_id = db.create_fiscal(
|
||||
conn, mes=7, ano=2026, supplier_name="Doc Legado", total_paid=40.0
|
||||
)
|
||||
self.assertIsNone(db.get_fiscal(conn, legacy_id)["categoria_id"])
|
||||
|
||||
# Documento processado pelo fluxo, sem match -> categoria_id = 1 (reservado).
|
||||
found_id = db.create_fiscal(
|
||||
conn, mes=7, ano=2026, supplier_name="Doc Não Encontrado", total_paid=15.0
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE fiscal_documents SET categoria_id = ? WHERE id = ?",
|
||||
(db.RESERVED_CATEGORIA_ID, found_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
totals = {r["categoria"]: r for r in db.category_totals(conn)}
|
||||
self.assertIn("Sem categoria", totals)
|
||||
self.assertEqual(totals["Sem categoria"]["total"], 40.0)
|
||||
self.assertIsNone(totals["Sem categoria"]["categoria_id"])
|
||||
self.assertIn("Não Encontrado", totals)
|
||||
self.assertEqual(totals["Não Encontrado"]["total"], 15.0)
|
||||
|
||||
none_filtered = db.list_fiscal(conn, category="none")
|
||||
self.assertEqual(len(none_filtered), 1)
|
||||
self.assertEqual(none_filtered[0]["id"], legacy_id)
|
||||
|
||||
reserved_filtered = db.list_fiscal(conn, category=str(db.RESERVED_CATEGORIA_ID))
|
||||
self.assertEqual(len(reserved_filtered), 1)
|
||||
self.assertEqual(reserved_filtered[0]["id"], found_id)
|
||||
|
||||
self.assertEqual(db.overall_totals(conn, category="none")["count"], 1)
|
||||
self.assertEqual(db.monthly_totals(conn, category="none")[0]["total"], 40.0)
|
||||
self.assertEqual(db.supplier_totals(conn, category="none")[0]["supplier_name"], "Doc Legado")
|
||||
|
||||
|
||||
class IngestionCategorizationIntegrationTests(_DbTestCase):
|
||||
"""6.3: fluxo de ingestão ponta a ponta (confirm_batch) resulta em categoria_id
|
||||
correto para os cenários de IA, palavra-chave e 'Não Encontrado'."""
|
||||
|
||||
def _stage(self, conn, batch_id, upload_id, supplier_name, total_paid):
|
||||
from app import database as db
|
||||
|
||||
return db.insert_detected(
|
||||
conn, upload_id=upload_id, batch_id=batch_id, source_file_name="f.pdf",
|
||||
source_page=None, source_location="f.pdf", raw_text="",
|
||||
mes=7, ano=2026, supplier_name=supplier_name, total_paid=total_paid,
|
||||
confidence="high", field_confidence={}, legible=True, uncertain_fields=[], extractor="local",
|
||||
)
|
||||
|
||||
def test_confirm_batch_assigns_categoria_via_ai_keyword_and_reserved(self):
|
||||
from app import database as db
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "test-key"
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
with db.session() as conn:
|
||||
saude_id = db.create_categoria(conn, "Saúde", "FARMACIA")
|
||||
alimentacao_id = db.create_categoria(conn, "Alimentação", "MERCADO")
|
||||
|
||||
batch = db.create_batch(conn)
|
||||
up = db.insert_upload(conn, batch, "f.pdf", Path("f.pdf"), "application/pdf", 1)
|
||||
self._stage(conn, batch, up, "Drogaria Central", 10.0) # IA acerta -> Saúde
|
||||
self._stage(conn, batch, up, "Mercado da Esquina", 20.0) # IA falha -> palavra-chave
|
||||
self._stage(conn, batch, up, "Loja Sem Categoria Nenhuma", 30.0) # nada casa -> reservado
|
||||
|
||||
def fake_ai(supplier_name, known_categories):
|
||||
return "Saúde" if supplier_name == "Drogaria Central" else None
|
||||
|
||||
with patch("app.categorization._call_ai", side_effect=fake_ai):
|
||||
inserted = db.confirm_batch(conn, batch)
|
||||
|
||||
self.assertEqual(inserted, 3)
|
||||
rows = {r["supplier_name"]: r for r in db.list_fiscal(conn)}
|
||||
self.assertEqual(rows["Drogaria Central"]["categoria_id"], saude_id)
|
||||
self.assertEqual(rows["Mercado da Esquina"]["categoria_id"], alimentacao_id)
|
||||
self.assertEqual(rows["Loja Sem Categoria Nenhuma"]["categoria_id"], db.RESERVED_CATEGORIA_ID)
|
||||
|
||||
def test_confirm_batch_saves_document_with_reserved_category_when_nothing_matches(self):
|
||||
"""3.2: nenhum erro bloqueia a criação do fiscal_documents; sem IA/palavra-chave
|
||||
casando, o documento é salvo com categoria_id=1 ("Não Encontrado")."""
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
batch = db.create_batch(conn)
|
||||
up = db.insert_upload(conn, batch, "f.pdf", Path("f.pdf"), "application/pdf", 1)
|
||||
self._stage(conn, batch, up, "Fornecedor Totalmente Novo", 10.0)
|
||||
|
||||
inserted = db.confirm_batch(conn, batch)
|
||||
|
||||
self.assertEqual(inserted, 1)
|
||||
row = db.list_fiscal(conn)[0]
|
||||
self.assertEqual(row["categoria_id"], db.RESERVED_CATEGORIA_ID)
|
||||
|
||||
|
||||
class CategoriaFormValidationRouteTests(_DbTestCase):
|
||||
"""Cobre, via requisição HTTP real (rota + CSRF + sessão), o cenário de spec
|
||||
'User submits an invalid categoria form' que não tinha teste automatizado."""
|
||||
|
||||
def _login(self, client):
|
||||
page = client.get("/login")
|
||||
token = re.search(r'name="csrf_token" value="([^"]+)"', page.text).group(1)
|
||||
client.post(
|
||||
"/login",
|
||||
data={"csrf_token": token, "username": "admin", "password": "test-pass-123"},
|
||||
)
|
||||
|
||||
def _csrf(self, page_html: str) -> str:
|
||||
return re.search(r'name="csrf_token" value="([^"]+)"', page_html).group(1)
|
||||
|
||||
def test_invalid_new_categoria_form_does_not_create_row(self):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import database as db
|
||||
from app.config import get_settings
|
||||
from app.main import app
|
||||
|
||||
os.environ["ADMIN_USERNAME"] = "admin"
|
||||
os.environ["ADMIN_PASSWORD"] = "test-pass-123"
|
||||
get_settings.cache_clear()
|
||||
|
||||
with TestClient(app) as client:
|
||||
self._login(client)
|
||||
|
||||
with db.session() as conn:
|
||||
before = len(db.list_categorias(conn))
|
||||
|
||||
new_page = client.get("/categorias/new")
|
||||
resp = client.post(
|
||||
"/categorias/new",
|
||||
data={"csrf_token": self._csrf(new_page.text), "categoria": "", "palavra_chave": ""},
|
||||
)
|
||||
self.assertIn("Informe categoria e palavra-chave", resp.text)
|
||||
|
||||
with db.session() as conn:
|
||||
after = len(db.list_categorias(conn))
|
||||
self.assertEqual(before, after)
|
||||
|
||||
def test_invalid_edit_categoria_form_does_not_change_row(self):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import database as db
|
||||
from app.config import get_settings
|
||||
from app.main import app
|
||||
|
||||
os.environ["ADMIN_USERNAME"] = "admin"
|
||||
os.environ["ADMIN_PASSWORD"] = "test-pass-123"
|
||||
get_settings.cache_clear()
|
||||
|
||||
with TestClient(app) as client:
|
||||
self._login(client)
|
||||
|
||||
with db.session() as conn:
|
||||
cat_id = db.create_categoria(conn, "Lazer", "CINEMA")
|
||||
|
||||
edit_page = client.get(f"/categorias/{cat_id}/edit")
|
||||
resp = client.post(
|
||||
f"/categorias/{cat_id}/edit",
|
||||
data={"csrf_token": self._csrf(edit_page.text), "categoria": "", "palavra_chave": ""},
|
||||
)
|
||||
self.assertIn("Informe categoria e palavra-chave", resp.text)
|
||||
|
||||
with db.session() as conn:
|
||||
row = db.get_categoria(conn, cat_id)
|
||||
self.assertEqual(row["categoria"], "Lazer")
|
||||
self.assertEqual(row["palavra_chave"], "CINEMA")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,114 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from lernotafiscal import db
|
||||
from lernotafiscal.extraction import detect_documents, extract_pdf_text_naive, segment_blocks
|
||||
from lernotafiscal.extraction import PageSource
|
||||
|
||||
|
||||
SINGLE_DOC = """
|
||||
MERCADO CENTRAL LTDA
|
||||
CNPJ 12.345.678/0001-90
|
||||
DATA 13/07/2026
|
||||
ITEM ARROZ 10,00
|
||||
VALOR TOTAL R$ 42,50
|
||||
"""
|
||||
|
||||
|
||||
MULTI_DOC = """
|
||||
MERCADO CENTRAL LTDA
|
||||
CNPJ 12.345.678/0001-90
|
||||
DATA 13/07/2026
|
||||
VALOR TOTAL R$ 42,50
|
||||
|
||||
FARMACIA SAUDE
|
||||
CNPJ 98.765.432/0001-10
|
||||
DATA 14/07/2026
|
||||
TOTAL A PAGAR R$ 18,90
|
||||
"""
|
||||
|
||||
|
||||
class ExtractionTests(unittest.TestCase):
|
||||
def test_single_image_text_creates_one_candidate(self) -> None:
|
||||
pages = [PageSource(1, SINGLE_DOC, None, "image")]
|
||||
docs = detect_documents(pages, "cupom.png")
|
||||
|
||||
self.assertEqual(len(docs), 1)
|
||||
self.assertEqual(docs[0].purchase_date, "2026-07-13")
|
||||
self.assertEqual(docs[0].supplier_name, "MERCADO CENTRAL LTDA")
|
||||
self.assertEqual(docs[0].total_paid, 42.50)
|
||||
|
||||
def test_pdf_page_with_multiple_blocks_creates_multiple_candidates(self) -> None:
|
||||
blocks = segment_blocks(MULTI_DOC)
|
||||
self.assertEqual(len(blocks), 2)
|
||||
|
||||
docs = detect_documents([PageSource(1, MULTI_DOC, None, "pdf")], "lote.pdf")
|
||||
self.assertEqual(len(docs), 2)
|
||||
self.assertEqual(docs[0].supplier_name, "MERCADO CENTRAL LTDA")
|
||||
self.assertEqual(docs[1].supplier_name, "FARMACIA SAUDE")
|
||||
|
||||
def test_pdf_with_one_document_per_page_creates_review_item_per_page(self) -> None:
|
||||
pages = [
|
||||
PageSource(1, SINGLE_DOC, None, "pdf"),
|
||||
PageSource(2, SINGLE_DOC.replace("MERCADO CENTRAL LTDA", "POSTO AVENIDA"), None, "pdf"),
|
||||
]
|
||||
docs = detect_documents(pages, "duas-paginas.pdf")
|
||||
|
||||
self.assertEqual(len(docs), 2)
|
||||
self.assertEqual(docs[0].source_location, "pagina 1, bloco 1")
|
||||
self.assertEqual(docs[1].source_location, "pagina 2, bloco 1")
|
||||
self.assertEqual(docs[1].supplier_name, "POSTO AVENIDA")
|
||||
|
||||
def test_naive_pdf_extraction_rejects_binary_streams_without_fake_pages(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pdf_path = Path(tmp) / "scan.pdf"
|
||||
pdf_path.write_bytes(
|
||||
b"%PDF-1.4\n"
|
||||
b"1 0 obj << /Type /Page >> endobj\n"
|
||||
b"2 0 obj << /Length 28 >> stream\n"
|
||||
b"\x01\x02binary\x0cnoise\x0cnot text\xff\n"
|
||||
b"endstream\n%%EOF"
|
||||
)
|
||||
|
||||
pages = extract_pdf_text_naive(pdf_path)
|
||||
|
||||
self.assertEqual(pages, [""])
|
||||
|
||||
def test_low_confidence_candidate_can_be_corrected_and_confirmed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
conn = db.connect(Path(tmp) / "test.sqlite3")
|
||||
try:
|
||||
db.init_db(conn)
|
||||
upload_id = db.insert_upload(conn, "scan.png", Path(tmp) / "scan.png", "image/png", 12)
|
||||
candidate = detect_documents([PageSource(1, "", None, "image")], "scan.png")[0]
|
||||
detected_id = db.insert_detected_document(conn, upload_id, candidate)
|
||||
|
||||
db.confirm_document(conn, detected_id, "2026-07-15", "PADARIA BOA", 9.75)
|
||||
docs = db.dashboard_documents(conn)
|
||||
|
||||
self.assertEqual(len(docs), 1)
|
||||
self.assertEqual(docs[0]["supplier_name"], "PADARIA BOA")
|
||||
self.assertEqual(docs[0]["total_paid"], 9.75)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_ignored_documents_do_not_reach_dashboard(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
conn = db.connect(Path(tmp) / "test.sqlite3")
|
||||
try:
|
||||
db.init_db(conn)
|
||||
upload_id = db.insert_upload(conn, "cupom.png", Path(tmp) / "cupom.png", "image/png", 12)
|
||||
candidate = detect_documents([PageSource(1, SINGLE_DOC, None, "image")], "cupom.png")[0]
|
||||
detected_id = db.insert_detected_document(conn, upload_id, candidate)
|
||||
|
||||
db.ignore_document(conn, detected_id)
|
||||
docs = db.dashboard_documents(conn)
|
||||
|
||||
self.assertEqual(docs, [])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user