diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 7bffddc..deb1ff3 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -23,7 +23,7 @@ func main() { defer database.Close() authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database) - handler := router.New(database, authMW) + handler := router.New(database, authMW, cfg.AnthropicAPIKey) log.Printf("Starting KanzlAI API server on :%s", cfg.Port) if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil { diff --git a/backend/go.mod b/backend/go.mod index 16c55c7..1217060 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,8 +3,14 @@ module mgit.msbls.de/m/KanzlAI-mGMT go 1.25.5 require ( + github.com/anthropics/anthropic-sdk-go v1.27.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jmoiron/sqlx v1.4.0 // indirect github.com/lib/pq v1.12.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + golang.org/x/sync v0.16.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index f8b06eb..3abb9a3 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,4 +1,6 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/anthropics/anthropic-sdk-go v1.27.1 h1:7DgMZ2Ng3C2mPzJGHA30NXQTZolcF07mHd0tGaLwfzk= +github.com/anthropics/anthropic-sdk-go v1.27.1/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= @@ -10,3 +12,15 @@ github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo= github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= diff --git a/backend/internal/handlers/ai.go b/backend/internal/handlers/ai.go new file mode 100644 index 0000000..806c9b3 --- /dev/null +++ b/backend/internal/handlers/ai.go @@ -0,0 +1,115 @@ +package handlers + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" +) + +type AIHandler struct { + ai *services.AIService + db *sqlx.DB +} + +func NewAIHandler(ai *services.AIService, db *sqlx.DB) *AIHandler { + return &AIHandler{ai: ai, db: db} +} + +// ExtractDeadlines handles POST /api/ai/extract-deadlines +// Accepts either multipart/form-data with a "file" PDF field, or JSON {"text": "..."}. +func (h *AIHandler) ExtractDeadlines(w http.ResponseWriter, r *http.Request) { + contentType := r.Header.Get("Content-Type") + + var pdfData []byte + var text string + + // Check if multipart (PDF upload) + if len(contentType) >= 9 && contentType[:9] == "multipart" { + if err := r.ParseMultipartForm(32 << 20); err != nil { // 32MB max + writeError(w, http.StatusBadRequest, "failed to parse multipart form") + return + } + file, _, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "missing 'file' field in multipart form") + return + } + defer file.Close() + + pdfData, err = io.ReadAll(file) + if err != nil { + writeError(w, http.StatusBadRequest, "failed to read uploaded file") + return + } + } else { + // Assume JSON body + var body struct { + Text string `json:"text"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + text = body.Text + } + + if len(pdfData) == 0 && text == "" { + writeError(w, http.StatusBadRequest, "provide either a PDF file or text") + return + } + + deadlines, err := h.ai.ExtractDeadlines(r.Context(), pdfData, text) + if err != nil { + writeError(w, http.StatusInternalServerError, "AI extraction failed: "+err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "deadlines": deadlines, + "count": len(deadlines), + }) +} + +// SummarizeCase handles POST /api/ai/summarize-case +// Accepts JSON {"case_id": "uuid"}. +func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) { + tenantID, err := resolveTenant(r, h.db) + if err != nil { + handleTenantError(w, err) + return + } + + var body struct { + CaseID string `json:"case_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if body.CaseID == "" { + writeError(w, http.StatusBadRequest, "case_id is required") + return + } + + caseID, err := parseUUID(body.CaseID) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid case_id") + return + } + + summary, err := h.ai.SummarizeCase(r.Context(), tenantID, caseID) + if err != nil { + writeError(w, http.StatusInternalServerError, "AI summarization failed: "+err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "case_id": caseID.String(), + "summary": summary, + }) +} diff --git a/backend/internal/handlers/appointments.go b/backend/internal/handlers/appointments.go index 49d8e16..16d1111 100644 --- a/backend/internal/handlers/appointments.go +++ b/backend/internal/handlers/appointments.go @@ -203,14 +203,3 @@ func (h *AppointmentHandler) Delete(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(v) -} - -func writeError(w http.ResponseWriter, status int, msg string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(map[string]string{"error": msg}) -} diff --git a/backend/internal/handlers/helpers.go b/backend/internal/handlers/helpers.go index 59a7e5a..785768e 100644 --- a/backend/internal/handlers/helpers.go +++ b/backend/internal/handlers/helpers.go @@ -83,3 +83,8 @@ func handleTenantError(w http.ResponseWriter, err error) { func parsePathUUID(r *http.Request, key string) (uuid.UUID, error) { return uuid.Parse(r.PathValue(key)) } + +// parseUUID parses a UUID string +func parseUUID(s string) (uuid.UUID, error) { + return uuid.Parse(s) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 4b21e0d..5e03cbe 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -11,7 +11,7 @@ import ( "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" ) -func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { +func New(db *sqlx.DB, authMW *auth.Middleware, anthropicAPIKey string) http.Handler { mux := http.NewServeMux() // Services @@ -24,6 +24,13 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { deadlineRuleSvc := services.NewDeadlineRuleService(db) calculator := services.NewDeadlineCalculator(holidaySvc) + // AI service (optional — only if API key is configured) + var aiH *handlers.AIHandler + if anthropicAPIKey != "" { + aiSvc := services.NewAIService(anthropicAPIKey, db) + aiH = handlers.NewAIHandler(aiSvc, db) + } + // Middleware tenantResolver := auth.NewTenantResolver(tenantSvc) @@ -86,6 +93,12 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update) scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete) + // AI endpoints + if aiH != nil { + scoped.HandleFunc("POST /api/ai/extract-deadlines", aiH.ExtractDeadlines) + scoped.HandleFunc("POST /api/ai/summarize-case", aiH.SummarizeCase) + } + // Placeholder routes for future phases scoped.HandleFunc("GET /api/documents", placeholder("documents")) diff --git a/backend/internal/services/ai_service.go b/backend/internal/services/ai_service.go new file mode 100644 index 0000000..d456cc3 --- /dev/null +++ b/backend/internal/services/ai_service.go @@ -0,0 +1,283 @@ +package services + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "time" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" +) + +type AIService struct { + client anthropic.Client + db *sqlx.DB +} + +func NewAIService(apiKey string, db *sqlx.DB) *AIService { + client := anthropic.NewClient(option.WithAPIKey(apiKey)) + return &AIService{client: client, db: db} +} + +// ExtractedDeadline represents a deadline extracted by AI from a document. +type ExtractedDeadline struct { + Title string `json:"title"` + DueDate *string `json:"due_date"` + DurationValue int `json:"duration_value"` + DurationUnit string `json:"duration_unit"` + Timing string `json:"timing"` + TriggerEvent string `json:"trigger_event"` + RuleReference string `json:"rule_reference"` + Confidence float64 `json:"confidence"` + SourceQuote string `json:"source_quote"` +} + +type extractDeadlinesToolInput struct { + Deadlines []ExtractedDeadline `json:"deadlines"` +} + +var deadlineExtractionTool = anthropic.ToolParam{ + Name: "extract_deadlines", + Description: anthropic.String("Extract all legal deadlines found in the document. Return each deadline with its details."), + InputSchema: anthropic.ToolInputSchemaParam{ + Properties: map[string]any{ + "deadlines": map[string]any{ + "type": "array", + "description": "List of extracted deadlines", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "title": map[string]any{ + "type": "string", + "description": "Short title describing the deadline (e.g. 'Statement of Defence', 'Reply to Counterclaim')", + }, + "due_date": map[string]any{ + "type": []string{"string", "null"}, + "description": "Absolute due date in YYYY-MM-DD format if determinable, null otherwise", + }, + "duration_value": map[string]any{ + "type": "integer", + "description": "Numeric duration value (e.g. 3 for '3 months')", + }, + "duration_unit": map[string]any{ + "type": "string", + "enum": []string{"days", "weeks", "months"}, + "description": "Unit of the duration period", + }, + "timing": map[string]any{ + "type": "string", + "enum": []string{"after", "before"}, + "description": "Whether the deadline is before or after the trigger event", + }, + "trigger_event": map[string]any{ + "type": "string", + "description": "The event that triggers this deadline (e.g. 'service of the Statement of Claim')", + }, + "rule_reference": map[string]any{ + "type": "string", + "description": "Legal rule reference (e.g. 'Rule 23 RoP', 'Rule 222 RoP', '§ 276 ZPO')", + }, + "confidence": map[string]any{ + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Confidence score from 0.0 to 1.0", + }, + "source_quote": map[string]any{ + "type": "string", + "description": "The exact quote from the document where this deadline was found", + }, + }, + "required": []string{"title", "duration_value", "duration_unit", "timing", "trigger_event", "rule_reference", "confidence", "source_quote"}, + }, + }, + }, + Required: []string{"deadlines"}, + }, +} + +const extractionSystemPrompt = `You are a legal deadline extraction assistant for German and UPC (Unified Patent Court) patent litigation. + +Your task is to extract all legal deadlines, time limits, and procedural time periods from the provided document. + +For each deadline found, extract: +- A clear title describing the deadline +- The absolute due date if it can be determined from the document +- The duration (value + unit: days/weeks/months) +- Whether it runs before or after a trigger event +- The trigger event that starts the deadline +- The legal rule reference (e.g. Rule 23 RoP, § 276 ZPO) +- Your confidence level (0.0-1.0) in the extraction +- The exact source quote from the document + +Be thorough: extract every deadline mentioned, including conditional ones. If a deadline references another deadline (e.g. "within 2 months of the defence"), capture that relationship in the trigger_event field. + +If the document contains no deadlines, return an empty list.` + +// ExtractDeadlines sends a document (PDF or text) to Claude for deadline extraction. +func (s *AIService) ExtractDeadlines(ctx context.Context, pdfData []byte, text string) ([]ExtractedDeadline, error) { + var contentBlocks []anthropic.ContentBlockParamUnion + + if len(pdfData) > 0 { + encoded := base64.StdEncoding.EncodeToString(pdfData) + contentBlocks = append(contentBlocks, anthropic.ContentBlockParamUnion{ + OfDocument: &anthropic.DocumentBlockParam{ + Source: anthropic.DocumentBlockParamSourceUnion{ + OfBase64: &anthropic.Base64PDFSourceParam{ + Data: encoded, + }, + }, + }, + }) + contentBlocks = append(contentBlocks, anthropic.NewTextBlock("Extract all legal deadlines from this document.")) + } else if text != "" { + contentBlocks = append(contentBlocks, anthropic.NewTextBlock("Extract all legal deadlines from the following text:\n\n"+text)) + } else { + return nil, fmt.Errorf("either pdf_data or text must be provided") + } + + msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{ + Model: anthropic.ModelClaudeSonnet4_5, + MaxTokens: 4096, + System: []anthropic.TextBlockParam{ + {Text: extractionSystemPrompt}, + }, + Messages: []anthropic.MessageParam{ + anthropic.NewUserMessage(contentBlocks...), + }, + Tools: []anthropic.ToolUnionParam{ + {OfTool: &deadlineExtractionTool}, + }, + ToolChoice: anthropic.ToolChoiceParamOfTool("extract_deadlines"), + }) + if err != nil { + return nil, fmt.Errorf("claude API call: %w", err) + } + + // Find the tool_use block in the response + for _, block := range msg.Content { + if block.Type == "tool_use" && block.Name == "extract_deadlines" { + var input extractDeadlinesToolInput + if err := json.Unmarshal(block.Input, &input); err != nil { + return nil, fmt.Errorf("parsing tool output: %w", err) + } + return input.Deadlines, nil + } + } + + return nil, fmt.Errorf("no tool_use block in response") +} + +const summarizeSystemPrompt = `You are a legal case summary assistant for German and UPC patent litigation case management. + +Given a case's details, recent events, and deadlines, produce a concise 2-3 sentence summary of what matters right now. Focus on: +- The most urgent upcoming deadline +- Recent significant events +- The current procedural stage + +Write in clear, professional language suitable for a lawyer reviewing their case list. Be specific about dates and deadlines.` + +// SummarizeCase generates an AI summary for a case and caches it in the database. +func (s *AIService) SummarizeCase(ctx context.Context, tenantID, caseID uuid.UUID) (string, error) { + // Load case + var c models.Case + err := s.db.GetContext(ctx, &c, + "SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID) + if err != nil { + return "", fmt.Errorf("loading case: %w", err) + } + + // Load recent events + var events []models.CaseEvent + if err := s.db.SelectContext(ctx, &events, + "SELECT * FROM case_events WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 10", + caseID, tenantID); err != nil { + return "", fmt.Errorf("loading events: %w", err) + } + + // Load active deadlines + var deadlines []models.Deadline + if err := s.db.SelectContext(ctx, &deadlines, + "SELECT * FROM deadlines WHERE case_id = $1 AND tenant_id = $2 AND status = 'active' ORDER BY due_date ASC LIMIT 10", + caseID, tenantID); err != nil { + return "", fmt.Errorf("loading deadlines: %w", err) + } + + // Build context text + caseInfo := fmt.Sprintf("Case: %s — %s\nStatus: %s", c.CaseNumber, c.Title, c.Status) + if c.Court != nil { + caseInfo += fmt.Sprintf("\nCourt: %s", *c.Court) + } + if c.CourtRef != nil { + caseInfo += fmt.Sprintf("\nCourt Reference: %s", *c.CourtRef) + } + if c.CaseType != nil { + caseInfo += fmt.Sprintf("\nType: %s", *c.CaseType) + } + + eventText := "\n\nRecent Events:" + if len(events) == 0 { + eventText += "\nNo events recorded." + } + for _, e := range events { + eventText += fmt.Sprintf("\n- [%s] %s", e.CreatedAt.Format("2006-01-02"), e.Title) + if e.Description != nil { + eventText += fmt.Sprintf(": %s", *e.Description) + } + } + + deadlineText := "\n\nUpcoming Deadlines:" + if len(deadlines) == 0 { + deadlineText += "\nNo active deadlines." + } + for _, d := range deadlines { + deadlineText += fmt.Sprintf("\n- %s: due %s (status: %s)", d.Title, d.DueDate, d.Status) + if d.Description != nil { + deadlineText += fmt.Sprintf(" — %s", *d.Description) + } + } + + prompt := caseInfo + eventText + deadlineText + + msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{ + Model: anthropic.ModelClaudeSonnet4_5, + MaxTokens: 512, + System: []anthropic.TextBlockParam{ + {Text: summarizeSystemPrompt}, + }, + Messages: []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock("Summarize the current state of this case:\n\n" + prompt)), + }, + }) + if err != nil { + return "", fmt.Errorf("claude API call: %w", err) + } + + // Extract text from response + var summary string + for _, block := range msg.Content { + if block.Type == "text" { + summary += block.Text + } + } + + if summary == "" { + return "", fmt.Errorf("empty response from Claude") + } + + // Cache summary in database + _, err = s.db.ExecContext(ctx, + "UPDATE cases SET ai_summary = $1, updated_at = $2 WHERE id = $3 AND tenant_id = $4", + summary, time.Now(), caseID, tenantID) + if err != nil { + return "", fmt.Errorf("caching summary: %w", err) + } + + return summary, nil +} diff --git a/backend/internal/services/ai_service_test.go b/backend/internal/services/ai_service_test.go new file mode 100644 index 0000000..11122f4 --- /dev/null +++ b/backend/internal/services/ai_service_test.go @@ -0,0 +1,109 @@ +package services + +import ( + "encoding/json" + "testing" +) + +func TestDeadlineExtractionToolSchema(t *testing.T) { + // Verify the tool schema serializes correctly + data, err := json.Marshal(deadlineExtractionTool) + if err != nil { + t.Fatalf("failed to marshal tool: %v", err) + } + + var parsed map[string]any + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("failed to unmarshal tool JSON: %v", err) + } + + if parsed["name"] != "extract_deadlines" { + t.Errorf("expected name 'extract_deadlines', got %v", parsed["name"]) + } + + schema, ok := parsed["input_schema"].(map[string]any) + if !ok { + t.Fatal("input_schema is not a map") + } + + if schema["type"] != "object" { + t.Errorf("expected schema type 'object', got %v", schema["type"]) + } + + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatal("properties is not a map") + } + + deadlines, ok := props["deadlines"].(map[string]any) + if !ok { + t.Fatal("deadlines property is not a map") + } + + if deadlines["type"] != "array" { + t.Errorf("expected deadlines type 'array', got %v", deadlines["type"]) + } + + items, ok := deadlines["items"].(map[string]any) + if !ok { + t.Fatal("items is not a map") + } + + itemProps, ok := items["properties"].(map[string]any) + if !ok { + t.Fatal("item properties is not a map") + } + + expectedFields := []string{"title", "due_date", "duration_value", "duration_unit", "timing", "trigger_event", "rule_reference", "confidence", "source_quote"} + for _, field := range expectedFields { + if _, ok := itemProps[field]; !ok { + t.Errorf("missing expected field %q in item properties", field) + } + } + + required, ok := items["required"].([]any) + if !ok { + t.Fatal("required is not a list") + } + if len(required) != 8 { + t.Errorf("expected 8 required fields, got %d", len(required)) + } +} + +func TestExtractedDeadlineJSON(t *testing.T) { + dueDate := "2026-04-15" + d := ExtractedDeadline{ + Title: "Statement of Defence", + DueDate: &dueDate, + DurationValue: 3, + DurationUnit: "months", + Timing: "after", + TriggerEvent: "service of the Statement of Claim", + RuleReference: "Rule 23 RoP", + Confidence: 0.95, + SourceQuote: "The defendant shall file a defence within 3 months", + } + + data, err := json.Marshal(d) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var parsed ExtractedDeadline + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if parsed.Title != d.Title { + t.Errorf("title mismatch: %q != %q", parsed.Title, d.Title) + } + if *parsed.DueDate != *d.DueDate { + t.Errorf("due_date mismatch: %q != %q", *parsed.DueDate, *d.DueDate) + } + if parsed.DurationValue != d.DurationValue { + t.Errorf("duration_value mismatch: %d != %d", parsed.DurationValue, d.DurationValue) + } + if parsed.Confidence != d.Confidence { + t.Errorf("confidence mismatch: %f != %f", parsed.Confidence, d.Confidence) + } +}