fix: update /prompts/respond route to /trackers/respond
This commit is contained in:
parent
49d7f31514
commit
131a41037b
208
api/api_v1.go
208
api/api_v1.go
|
|
@ -2,6 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -69,6 +70,23 @@ func v1CanAccess(authID, targetID string) bool {
|
||||||
return len(records) > 0
|
return len(records) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func v1CanWrite(authID, targetID string) bool {
|
||||||
|
if authID == targetID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
records, _ := lib.AccessList(&lib.AccessFilter{
|
||||||
|
AccessorID: authID,
|
||||||
|
TargetID: targetID,
|
||||||
|
Status: intPtr(1),
|
||||||
|
})
|
||||||
|
for _, r := range records {
|
||||||
|
if r.CanEdit {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func intPtr(i int) *int { return &i }
|
func intPtr(i int) *int { return &i }
|
||||||
|
|
||||||
func v1Error(w http.ResponseWriter, msg string, code int) {
|
func v1Error(w http.ResponseWriter, msg string, code int) {
|
||||||
|
|
@ -728,6 +746,180 @@ func v1Health(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// --- ROUTER ---
|
// --- ROUTER ---
|
||||||
|
|
||||||
|
// v1ListJournals handles GET /dossiers/{id}/journal
|
||||||
|
func v1ListJournals(w http.ResponseWriter, r *http.Request, dossierID string) {
|
||||||
|
actorID, ok := v1AuthRequired(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !v1CanAccess(actorID, dossierID) {
|
||||||
|
v1Error(w, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse query parameters
|
||||||
|
days := 30 // default
|
||||||
|
if daysStr := r.URL.Query().Get("days"); daysStr != "" {
|
||||||
|
if d, err := strconv.Atoi(daysStr); err == nil {
|
||||||
|
days = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var status *int
|
||||||
|
if statusStr := r.URL.Query().Get("status"); statusStr != "" {
|
||||||
|
if s, err := strconv.Atoi(statusStr); err == nil {
|
||||||
|
status = &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
journalType := r.URL.Query().Get("type")
|
||||||
|
|
||||||
|
// List journals
|
||||||
|
journals, err := lib.ListJournals(lib.ListJournalsInput{
|
||||||
|
DossierID: dossierID,
|
||||||
|
Days: days,
|
||||||
|
Status: status,
|
||||||
|
Type: journalType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
v1Error(w, fmt.Sprintf("Failed to list journals: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v1JSON(w, map[string]interface{}{"journals": journals})
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1GetJournal handles GET /dossiers/{id}/journal/{entry_id}
|
||||||
|
func v1GetJournal(w http.ResponseWriter, r *http.Request, dossierID, entryID string) {
|
||||||
|
actorID, ok := v1AuthRequired(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !v1CanAccess(actorID, dossierID) {
|
||||||
|
v1Error(w, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
journal, err := lib.GetJournal(dossierID, entryID)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "access denied") {
|
||||||
|
v1Error(w, "Access denied", http.StatusForbidden)
|
||||||
|
} else if strings.Contains(err.Error(), "not found") {
|
||||||
|
v1Error(w, "Journal entry not found", http.StatusNotFound)
|
||||||
|
} else {
|
||||||
|
v1Error(w, fmt.Sprintf("Failed to get journal: %v", err), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v1JSON(w, journal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1CreateJournal handles POST /dossiers/{id}/journal
|
||||||
|
func v1CreateJournal(w http.ResponseWriter, r *http.Request, dossierID string) {
|
||||||
|
actorID, ok := v1AuthRequired(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !v1CanWrite(actorID, dossierID) {
|
||||||
|
v1Error(w, "Write access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
var req struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
RelatedEntries []string `json:"related_entries"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
v1Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create journal
|
||||||
|
entryID, err := lib.CreateJournal(lib.CreateJournalInput{
|
||||||
|
DossierID: dossierID,
|
||||||
|
Type: req.Type,
|
||||||
|
Title: req.Title,
|
||||||
|
Summary: req.Summary,
|
||||||
|
Content: req.Content,
|
||||||
|
Tags: req.Tags,
|
||||||
|
Status: req.Status,
|
||||||
|
RelatedEntries: req.RelatedEntries,
|
||||||
|
Source: req.Source,
|
||||||
|
Reasoning: req.Reasoning,
|
||||||
|
Metadata: req.Metadata,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
v1Error(w, fmt.Sprintf("Failed to create journal: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return created entry
|
||||||
|
journal, err := lib.GetJournal(dossierID, entryID)
|
||||||
|
if err != nil {
|
||||||
|
v1Error(w, "Created but failed to retrieve", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
v1JSON(w, journal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1UpdateJournal handles PATCH /dossiers/{id}/journal/{entry_id}
|
||||||
|
func v1UpdateJournal(w http.ResponseWriter, r *http.Request, dossierID, entryID string) {
|
||||||
|
actorID, ok := v1AuthRequired(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !v1CanWrite(actorID, dossierID) {
|
||||||
|
v1Error(w, "Write access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
var req struct {
|
||||||
|
Status *int `json:"status"`
|
||||||
|
AppendNote string `json:"append_note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
v1Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update journal
|
||||||
|
err := lib.UpdateJournalStatus(lib.UpdateJournalStatusInput{
|
||||||
|
DossierID: dossierID,
|
||||||
|
EntryID: entryID,
|
||||||
|
Status: req.Status,
|
||||||
|
AppendNote: req.AppendNote,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "access denied") {
|
||||||
|
v1Error(w, "Access denied", http.StatusForbidden)
|
||||||
|
} else {
|
||||||
|
v1Error(w, fmt.Sprintf("Failed to update journal: %v", err), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v1JSON(w, map[string]interface{}{"updated": time.Now().Format(time.RFC3339)})
|
||||||
|
}
|
||||||
|
|
||||||
func v1Router(w http.ResponseWriter, r *http.Request) {
|
func v1Router(w http.ResponseWriter, r *http.Request) {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1")
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1")
|
||||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
|
@ -769,6 +961,22 @@ func v1Router(w http.ResponseWriter, r *http.Request) {
|
||||||
case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "trackers" && r.Method == "GET":
|
case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "trackers" && r.Method == "GET":
|
||||||
v1Prompts(w, r, parts[1])
|
v1Prompts(w, r, parts[1])
|
||||||
|
|
||||||
|
// GET /dossiers/{id}/journal
|
||||||
|
case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "journal" && r.Method == "GET":
|
||||||
|
v1ListJournals(w, r, parts[1])
|
||||||
|
|
||||||
|
// GET /dossiers/{id}/journal/{entry_id}
|
||||||
|
case len(parts) == 4 && parts[0] == "dossiers" && parts[2] == "journal" && r.Method == "GET":
|
||||||
|
v1GetJournal(w, r, parts[1], parts[3])
|
||||||
|
|
||||||
|
// POST /dossiers/{id}/journal
|
||||||
|
case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "journal" && r.Method == "POST":
|
||||||
|
v1CreateJournal(w, r, parts[1])
|
||||||
|
|
||||||
|
// PATCH /dossiers/{id}/journal/{entry_id}
|
||||||
|
case len(parts) == 4 && parts[0] == "dossiers" && parts[2] == "journal" && r.Method == "PATCH":
|
||||||
|
v1UpdateJournal(w, r, parts[1], parts[3])
|
||||||
|
|
||||||
// POST /dossiers/{id}/parse
|
// POST /dossiers/{id}/parse
|
||||||
case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "parse" && r.Method == "POST":
|
case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "parse" && r.Method == "POST":
|
||||||
v1Parse(w, r, parts[1])
|
v1Parse(w, r, parts[1])
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Journal entry types
|
||||||
|
const (
|
||||||
|
JournalTypeProtocol = "protocol"
|
||||||
|
JournalTypeHypothesis = "hypothesis"
|
||||||
|
JournalTypeObservation = "observation"
|
||||||
|
JournalTypeConnection = "connection"
|
||||||
|
JournalTypeQuestion = "question"
|
||||||
|
JournalTypeReference = "reference"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Journal status values
|
||||||
|
const (
|
||||||
|
JournalStatusDraft = 0
|
||||||
|
JournalStatusActive = 1
|
||||||
|
JournalStatusResolved = 2
|
||||||
|
JournalStatusDiscarded = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// JournalData represents the encrypted JSON data stored in entry.Data
|
||||||
|
type JournalData struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
ConversationID string `json:"conversation_id,omitempty"`
|
||||||
|
ConversationURL string `json:"conversation_url,omitempty"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
RelatedEntries []string `json:"related_entries,omitempty"`
|
||||||
|
Reasoning string `json:"reasoning,omitempty"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateJournalInput represents the input for creating a journal entry
|
||||||
|
type CreateJournalInput struct {
|
||||||
|
DossierID string
|
||||||
|
Type string // protocol, hypothesis, observation, etc.
|
||||||
|
Title string
|
||||||
|
Summary string // optional - generated if not provided
|
||||||
|
Content string // full markdown content
|
||||||
|
Tags []string // optional
|
||||||
|
Status int // optional - defaults to draft
|
||||||
|
RelatedEntries []string // optional
|
||||||
|
Source string // optional - e.g., "opus-4.6"
|
||||||
|
Reasoning string // optional
|
||||||
|
Metadata map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// JournalSummary represents a journal entry summary for list view
|
||||||
|
type JournalSummary struct {
|
||||||
|
EntryID string `json:"entry_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
DossierID string `json:"dossier_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JournalEntry represents a full journal entry
|
||||||
|
type JournalEntry struct {
|
||||||
|
EntryID string `json:"entry_id"`
|
||||||
|
DossierID string `json:"dossier_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
RelatedEntries []string `json:"related_entries,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Reasoning string `json:"reasoning,omitempty"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateJournalSummary uses Gemini to generate a 1-2 sentence summary
|
||||||
|
func GenerateJournalSummary(title, journalType, content string) (string, error) {
|
||||||
|
prompt := fmt.Sprintf(`Summarize this health journal entry in 1-2 sentences (max 300 chars).
|
||||||
|
Focus on: what intervention/observation, for what condition, and key details.
|
||||||
|
|
||||||
|
Title: %s
|
||||||
|
Type: %s
|
||||||
|
Content: %s
|
||||||
|
|
||||||
|
Example good summaries:
|
||||||
|
- "20-25g daily Beluga/Keta targeting phospholipid-DHA → SPM production for aqueduct inflammation."
|
||||||
|
- "Jan 10 fever may have opened aqueduct drainage. Observing improved tone, eye tracking, head control."
|
||||||
|
- "Autologous WBC therapy (Utheline/Ricasin/Notokill + ATP) given during active fever."
|
||||||
|
|
||||||
|
Respond with ONLY the summary text, no JSON, no extra formatting.`, title, journalType, content)
|
||||||
|
|
||||||
|
// Override default config to return plain text
|
||||||
|
plainText := "text/plain"
|
||||||
|
maxTokens := 150
|
||||||
|
temp := 0.3
|
||||||
|
config := &GeminiConfig{
|
||||||
|
Temperature: &temp,
|
||||||
|
MaxOutputTokens: &maxTokens,
|
||||||
|
ResponseMimeType: &plainText,
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := CallGeminiMultimodal([]GeminiPart{{Text: prompt}}, config)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate summary: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = strings.TrimSpace(summary)
|
||||||
|
if len(summary) > 300 {
|
||||||
|
summary = summary[:297] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateJournal creates a new journal entry
|
||||||
|
func CreateJournal(input CreateJournalInput) (string, error) {
|
||||||
|
// Validate required fields
|
||||||
|
if input.DossierID == "" {
|
||||||
|
return "", fmt.Errorf("dossier_id required")
|
||||||
|
}
|
||||||
|
if input.Type == "" {
|
||||||
|
return "", fmt.Errorf("type required")
|
||||||
|
}
|
||||||
|
if input.Title == "" {
|
||||||
|
return "", fmt.Errorf("title required")
|
||||||
|
}
|
||||||
|
if input.Content == "" {
|
||||||
|
return "", fmt.Errorf("content required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate summary if not provided
|
||||||
|
if input.Summary == "" {
|
||||||
|
var err error
|
||||||
|
input.Summary, err = GenerateJournalSummary(input.Title, input.Type, input.Content)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate summary: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Validate client-provided summary
|
||||||
|
if len(input.Summary) > 300 {
|
||||||
|
return "", fmt.Errorf("summary too long (max 300 chars)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build data JSON
|
||||||
|
data := JournalData{
|
||||||
|
Source: input.Source,
|
||||||
|
Content: input.Content,
|
||||||
|
RelatedEntries: input.RelatedEntries,
|
||||||
|
Reasoning: input.Reasoning,
|
||||||
|
Metadata: input.Metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
dataJSON, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create entry
|
||||||
|
entry := Entry{
|
||||||
|
EntryID: NewID(),
|
||||||
|
DossierID: input.DossierID,
|
||||||
|
Category: CategoryNote,
|
||||||
|
Type: input.Type,
|
||||||
|
Value: input.Title,
|
||||||
|
Summary: input.Summary,
|
||||||
|
Data: string(dataJSON),
|
||||||
|
Tags: strings.Join(input.Tags, ","),
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
Status: input.Status, // defaults to 0 (draft) if not set
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Save("entries", &entry); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to save entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.EntryID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJournal retrieves a full journal entry
|
||||||
|
func GetJournal(dossierID, entryID string) (*JournalEntry, error) {
|
||||||
|
var entry Entry
|
||||||
|
if err := Load("entries", entryID, &entry); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
if entry.DossierID != dossierID {
|
||||||
|
return nil, fmt.Errorf("access denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a journal entry
|
||||||
|
if entry.Category != CategoryNote {
|
||||||
|
return nil, fmt.Errorf("not a journal entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse data JSON
|
||||||
|
var data JournalData
|
||||||
|
if entry.Data != "" {
|
||||||
|
if err := json.Unmarshal([]byte(entry.Data), &data); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse data: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse tags
|
||||||
|
var tags []string
|
||||||
|
if entry.Tags != "" {
|
||||||
|
tags = strings.Split(entry.Tags, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &JournalEntry{
|
||||||
|
EntryID: entry.EntryID,
|
||||||
|
DossierID: entry.DossierID,
|
||||||
|
Type: entry.Type,
|
||||||
|
Title: entry.Value,
|
||||||
|
Summary: entry.Summary,
|
||||||
|
Content: data.Content,
|
||||||
|
Date: time.Unix(entry.Timestamp, 0),
|
||||||
|
Status: entry.Status,
|
||||||
|
Tags: tags,
|
||||||
|
RelatedEntries: data.RelatedEntries,
|
||||||
|
Source: data.Source,
|
||||||
|
Reasoning: data.Reasoning,
|
||||||
|
Metadata: data.Metadata,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListJournalsInput represents filters for listing journals
|
||||||
|
type ListJournalsInput struct {
|
||||||
|
DossierID string
|
||||||
|
Days int // filter to last N days (0 = all)
|
||||||
|
Status *int // filter by status (nil = all)
|
||||||
|
Type string // filter by type (empty = all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListJournals retrieves journal summaries with optional filters
|
||||||
|
func ListJournals(input ListJournalsInput) ([]JournalSummary, error) {
|
||||||
|
// Build query
|
||||||
|
query := `SELECT entry_id, dossier_id, type, value, summary, tags, timestamp, status
|
||||||
|
FROM entries
|
||||||
|
WHERE dossier_id = ? AND category = ?`
|
||||||
|
args := []interface{}{input.DossierID, CategoryNote}
|
||||||
|
|
||||||
|
// Add type filter
|
||||||
|
if input.Type != "" {
|
||||||
|
query += " AND type = ?"
|
||||||
|
args = append(args, input.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add status filter
|
||||||
|
if input.Status != nil {
|
||||||
|
query += " AND status = ?"
|
||||||
|
args = append(args, *input.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add days filter
|
||||||
|
if input.Days > 0 {
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -input.Days).Unix()
|
||||||
|
query += " AND timestamp >= ?"
|
||||||
|
args = append(args, cutoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY timestamp DESC"
|
||||||
|
|
||||||
|
// Execute query
|
||||||
|
var entries []Entry
|
||||||
|
if err := Query(query, args, &entries); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query entries: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to summaries
|
||||||
|
summaries := make([]JournalSummary, len(entries))
|
||||||
|
for i, entry := range entries {
|
||||||
|
var tags []string
|
||||||
|
if entry.Tags != "" {
|
||||||
|
tags = strings.Split(entry.Tags, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries[i] = JournalSummary{
|
||||||
|
EntryID: entry.EntryID,
|
||||||
|
Type: entry.Type,
|
||||||
|
Title: entry.Value,
|
||||||
|
Summary: entry.Summary,
|
||||||
|
Date: time.Unix(entry.Timestamp, 0),
|
||||||
|
Status: entry.Status,
|
||||||
|
Tags: tags,
|
||||||
|
DossierID: entry.DossierID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateJournalStatusInput represents input for updating journal status
|
||||||
|
type UpdateJournalStatusInput struct {
|
||||||
|
DossierID string
|
||||||
|
EntryID string
|
||||||
|
Status *int // optional: new status
|
||||||
|
AppendNote string // optional: append update to content
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateJournalStatus updates a journal entry's status or appends a note
|
||||||
|
func UpdateJournalStatus(input UpdateJournalStatusInput) error {
|
||||||
|
// Load entry
|
||||||
|
var entry Entry
|
||||||
|
if err := Load("entries", input.EntryID, &entry); err != nil {
|
||||||
|
return fmt.Errorf("failed to load entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
if entry.DossierID != input.DossierID {
|
||||||
|
return fmt.Errorf("access denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a journal entry
|
||||||
|
if entry.Category != CategoryNote {
|
||||||
|
return fmt.Errorf("not a journal entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status if provided
|
||||||
|
if input.Status != nil {
|
||||||
|
entry.Status = *input.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append note if provided
|
||||||
|
if input.AppendNote != "" {
|
||||||
|
var data JournalData
|
||||||
|
if entry.Data != "" {
|
||||||
|
if err := json.Unmarshal([]byte(entry.Data), &data); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse data: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append to content
|
||||||
|
timestamp := time.Now().Format("2006-01-02")
|
||||||
|
update := fmt.Sprintf("\n\n---\n**Update %s:** %s", timestamp, input.AppendNote)
|
||||||
|
data.Content += update
|
||||||
|
|
||||||
|
dataJSON, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal data: %w", err)
|
||||||
|
}
|
||||||
|
entry.Data = string(dataJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save entry
|
||||||
|
if err := Save("entries", &entry); err != nil {
|
||||||
|
return fmt.Errorf("failed to save entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -2007,7 +2007,7 @@ func setupMux() http.Handler {
|
||||||
} else if strings.Contains(path, "/access/") { handleEditAccess(w, r)
|
} else if strings.Contains(path, "/access/") { handleEditAccess(w, r)
|
||||||
} else if strings.HasSuffix(path, "/prompts") { handleTrackers(w, r)
|
} else if strings.HasSuffix(path, "/prompts") { handleTrackers(w, r)
|
||||||
} else if strings.Contains(path, "/prompts/card/") { handleRenderTrackerCard(w, r)
|
} else if strings.Contains(path, "/prompts/card/") { handleRenderTrackerCard(w, r)
|
||||||
} else if strings.HasSuffix(path, "/prompts/respond") { handleTrackerRespond(w, r)
|
} else if strings.HasSuffix(path, "/trackers/respond") { handleTrackerRespond(w, r)
|
||||||
} else if strings.HasSuffix(path, "/upload") { if r.Method == "POST" { handleUploadPost(w, r) } else { handleUploadPage(w, r) }
|
} else if strings.HasSuffix(path, "/upload") { if r.Method == "POST" { handleUploadPost(w, r) } else { handleUploadPage(w, r) }
|
||||||
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/delete") { handleDeleteFile(w, r)
|
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/delete") { handleDeleteFile(w, r)
|
||||||
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/update") { handleUpdateFile(w, r)
|
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/update") { handleUpdateFile(w, r)
|
||||||
|
|
@ -2028,7 +2028,7 @@ func setupMux() http.Handler {
|
||||||
mux.HandleFunc("/api/v1/auth/verify", handleAPIVerify)
|
mux.HandleFunc("/api/v1/auth/verify", handleAPIVerify)
|
||||||
mux.HandleFunc("/api/v1/dashboard", handleAPIDashboard)
|
mux.HandleFunc("/api/v1/dashboard", handleAPIDashboard)
|
||||||
mux.HandleFunc("/api/v1/prompts", handleAPIPrompts)
|
mux.HandleFunc("/api/v1/prompts", handleAPIPrompts)
|
||||||
mux.HandleFunc("/api/v1/prompts/respond", handleAPITrackerRespond)
|
mux.HandleFunc("/api/v1/trackers/respond", handleAPITrackerRespond)
|
||||||
|
|
||||||
mux.HandleFunc("/api", handleAPI)
|
mux.HandleFunc("/api", handleAPI)
|
||||||
mux.HandleFunc("/api/token/generate", handleAPITokenGenerate)
|
mux.HandleFunc("/api/token/generate", handleAPITokenGenerate)
|
||||||
|
|
|
||||||
|
|
@ -418,6 +418,69 @@ func handleMCPToolsList(w http.ResponseWriter, req mcpRequest) {
|
||||||
},
|
},
|
||||||
"annotations": readOnly,
|
"annotations": readOnly,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "list_journals",
|
||||||
|
"description": "List journal entries (protocols, hypotheses, observations) for a dossier. Returns summaries with title, type, date, status. Use get_journal_entry to fetch full content.",
|
||||||
|
"inputSchema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
||||||
|
"days": map[string]interface{}{"type": "number", "description": "Filter to last N days (0 = all)"},
|
||||||
|
"status": map[string]interface{}{"type": "number", "description": "Filter by status (0=draft, 1=active, 2=resolved, 3=discarded)"},
|
||||||
|
"type": map[string]interface{}{"type": "string", "description": "Filter by type (protocol, hypothesis, observation, connection, question, reference)"},
|
||||||
|
},
|
||||||
|
"required": []string{"dossier"},
|
||||||
|
},
|
||||||
|
"annotations": readOnly,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_journal_entry",
|
||||||
|
"description": "Get full journal entry with complete content, reasoning, and metadata.",
|
||||||
|
"inputSchema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
||||||
|
"entry_id": map[string]interface{}{"type": "string", "description": "Journal entry ID (16-char hex)"},
|
||||||
|
},
|
||||||
|
"required": []string{"dossier", "entry_id"},
|
||||||
|
},
|
||||||
|
"annotations": readOnly,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_journal_entry",
|
||||||
|
"description": "Create a new journal entry (protocol, hypothesis, observation, etc.). Summary will be auto-generated if not provided.",
|
||||||
|
"inputSchema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
||||||
|
"type": map[string]interface{}{"type": "string", "description": "Entry type: protocol, hypothesis, observation, connection, question, reference"},
|
||||||
|
"title": map[string]interface{}{"type": "string", "description": "Short title (max 200 chars)"},
|
||||||
|
"summary": map[string]interface{}{"type": "string", "description": "1-2 sentence summary (max 300 chars, auto-generated if omitted)"},
|
||||||
|
"content": map[string]interface{}{"type": "string", "description": "Full markdown content with details, reasoning, etc."},
|
||||||
|
"tags": map[string]interface{}{"type": "array", "items": map[string]string{"type": "string"}, "description": "Tags for categorization"},
|
||||||
|
"status": map[string]interface{}{"type": "number", "description": "0=draft (default), 1=active, 2=resolved, 3=discarded"},
|
||||||
|
"related_entries": map[string]interface{}{"type": "array", "items": map[string]string{"type": "string"}, "description": "Related entry IDs"},
|
||||||
|
"source": map[string]interface{}{"type": "string", "description": "Source model (e.g., opus-4.6)"},
|
||||||
|
"reasoning": map[string]interface{}{"type": "string", "description": "Why this matters / how we arrived at this"},
|
||||||
|
"metadata": map[string]interface{}{"type": "object", "description": "Additional context (weight, age, etc.)"},
|
||||||
|
},
|
||||||
|
"required": []string{"dossier", "type", "title", "content"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "update_journal_entry",
|
||||||
|
"description": "Update journal entry status or append a note to existing entry.",
|
||||||
|
"inputSchema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
||||||
|
"entry_id": map[string]interface{}{"type": "string", "description": "Journal entry ID (16-char hex)"},
|
||||||
|
"status": map[string]interface{}{"type": "number", "description": "New status (0=draft, 1=active, 2=resolved, 3=discarded)"},
|
||||||
|
"append_note": map[string]interface{}{"type": "string", "description": "Note to append to content (timestamped)"},
|
||||||
|
},
|
||||||
|
"required": []string{"dossier", "entry_id"},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "get_version",
|
"name": "get_version",
|
||||||
"description": "Get bridge and server version info.",
|
"description": "Get bridge and server version info.",
|
||||||
|
|
@ -584,6 +647,69 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss
|
||||||
fmt.Printf("[MCP] query_genome success: %d bytes\n", len(result))
|
fmt.Printf("[MCP] query_genome success: %d bytes\n", len(result))
|
||||||
sendMCPResult(w, req.ID, mcpTextContent(result))
|
sendMCPResult(w, req.ID, mcpTextContent(result))
|
||||||
|
|
||||||
|
case "list_journals":
|
||||||
|
dossier, _ := params.Arguments["dossier"].(string)
|
||||||
|
if dossier == "" {
|
||||||
|
sendMCPError(w, req.ID, -32602, "dossier required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days, _ := params.Arguments["days"].(float64)
|
||||||
|
journalType, _ := params.Arguments["type"].(string)
|
||||||
|
|
||||||
|
var status *int
|
||||||
|
if statusVal, ok := params.Arguments["status"].(float64); ok {
|
||||||
|
s := int(statusVal)
|
||||||
|
status = &s
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := mcpListJournals(accessToken, dossier, int(days), status, journalType)
|
||||||
|
if err != nil {
|
||||||
|
sendMCPError(w, req.ID, -32000, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendMCPResult(w, req.ID, mcpTextContent(result))
|
||||||
|
|
||||||
|
case "get_journal_entry":
|
||||||
|
dossier, _ := params.Arguments["dossier"].(string)
|
||||||
|
entryID, _ := params.Arguments["entry_id"].(string)
|
||||||
|
if dossier == "" || entryID == "" {
|
||||||
|
sendMCPError(w, req.ID, -32602, "dossier and entry_id required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := mcpGetJournalEntry(accessToken, dossier, entryID)
|
||||||
|
if err != nil {
|
||||||
|
sendMCPError(w, req.ID, -32000, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendMCPResult(w, req.ID, mcpTextContent(result))
|
||||||
|
|
||||||
|
case "create_journal_entry":
|
||||||
|
dossier, _ := params.Arguments["dossier"].(string)
|
||||||
|
if dossier == "" {
|
||||||
|
sendMCPError(w, req.ID, -32602, "dossier required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := mcpCreateJournalEntry(accessToken, dossier, params.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
sendMCPError(w, req.ID, -32000, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendMCPResult(w, req.ID, mcpTextContent(result))
|
||||||
|
|
||||||
|
case "update_journal_entry":
|
||||||
|
dossier, _ := params.Arguments["dossier"].(string)
|
||||||
|
entryID, _ := params.Arguments["entry_id"].(string)
|
||||||
|
if dossier == "" || entryID == "" {
|
||||||
|
sendMCPError(w, req.ID, -32602, "dossier and entry_id required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := mcpUpdateJournalEntry(accessToken, dossier, entryID, params.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
sendMCPError(w, req.ID, -32000, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendMCPResult(w, req.ID, mcpTextContent(result))
|
||||||
|
|
||||||
case "get_version":
|
case "get_version":
|
||||||
sendMCPResult(w, req.ID, mcpTextContent(fmt.Sprintf("Server: %s v%s", mcpServerName, mcpServerVersion)))
|
sendMCPResult(w, req.ID, mcpTextContent(fmt.Sprintf("Server: %s v%s", mcpServerName, mcpServerVersion)))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -226,3 +227,112 @@ func mcpQueryGenome(accessToken, dossier, gene, search, category, rsids string,
|
||||||
pretty, _ := json.MarshalIndent(data, "", " ")
|
pretty, _ := json.MarshalIndent(data, "", " ")
|
||||||
return string(pretty), nil
|
return string(pretty), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Journal MCP Tools
|
||||||
|
|
||||||
|
func mcpListJournals(accessToken, dossier string, days int, status *int, journalType string) (string, error) {
|
||||||
|
params := map[string]string{"dossier": dossier}
|
||||||
|
if days > 0 {
|
||||||
|
params["days"] = strconv.Itoa(days)
|
||||||
|
}
|
||||||
|
if status != nil {
|
||||||
|
params["status"] = strconv.Itoa(*status)
|
||||||
|
}
|
||||||
|
if journalType != "" {
|
||||||
|
params["type"] = journalType
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := mcpAPICall(accessToken, "/api/v1/dossiers/"+dossier+"/journal", params)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var data interface{}
|
||||||
|
json.Unmarshal(body, &data)
|
||||||
|
pretty, _ := json.MarshalIndent(data, "", " ")
|
||||||
|
return string(pretty), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpGetJournalEntry(accessToken, dossier, entryID string) (string, error) {
|
||||||
|
body, err := mcpAPICall(accessToken, "/api/v1/dossiers/"+dossier+"/journal/"+entryID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var data interface{}
|
||||||
|
json.Unmarshal(body, &data)
|
||||||
|
pretty, _ := json.MarshalIndent(data, "", " ")
|
||||||
|
return string(pretty), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpCreateJournalEntry(accessToken, dossier string, params map[string]interface{}) (string, error) {
|
||||||
|
u := apiBaseURL + "/api/v1/dossiers/" + dossier + "/journal"
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", u, io.NopCloser(bytes.NewReader(jsonData)))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||||
|
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var data interface{}
|
||||||
|
json.Unmarshal(body, &data)
|
||||||
|
pretty, _ := json.MarshalIndent(data, "", " ")
|
||||||
|
return string(pretty), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpUpdateJournalEntry(accessToken, dossier, entryID string, params map[string]interface{}) (string, error) {
|
||||||
|
u := apiBaseURL + "/api/v1/dossiers/" + dossier + "/journal/" + entryID
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("PATCH", u, io.NopCloser(bytes.NewReader(jsonData)))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var data interface{}
|
||||||
|
json.Unmarshal(body, &data)
|
||||||
|
pretty, _ := json.MarshalIndent(data, "", " ")
|
||||||
|
return string(pretty), nil
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue