diff --git a/api/api_genome.go b/api/api_genome.go index 7abe6eb..124e668 100644 --- a/api/api_genome.go +++ b/api/api_genome.go @@ -3,31 +3,12 @@ package main import ( "encoding/json" "net/http" - "sort" "strconv" "strings" "inou/lib" ) -type GenomeMatch struct { - RSID string `json:"rsid"` - Genotype string `json:"genotype"` - Gene string `json:"gene,omitempty"` - Matched bool `json:"matched"` - Magnitude *float64 `json:"magnitude,omitempty"` - Repute string `json:"repute,omitempty"` - Summary string `json:"summary,omitempty"` - Category string `json:"category,omitempty"` - Subcategory string `json:"subcategory,omitempty"` -} - -type GenomeResponse struct { - Matches []GenomeMatch `json:"matches"` - Returned int `json:"returned"` - Total int `json:"total"` -} - func handleGenomeQuery(w http.ResponseWriter, r *http.Request) { ctx := getAccessContextOrFail(w, r) if ctx == nil { @@ -40,224 +21,48 @@ func handleGenomeQuery(w http.ResponseWriter, r *http.Request) { return } - // Check dossier access first if !requireDossierAccess(w, ctx, dossierID) { return } - // Use system context for genome queries (dossier access already checked) - sysCtx := &lib.AccessContext{IsSystem: true} - - category := r.URL.Query().Get("category") - search := r.URL.Query().Get("search") - rsidsParam := r.URL.Query().Get("rsids") - gene := r.URL.Query().Get("gene") - includeHidden := r.URL.Query().Get("include_hidden") == "true" - minMagStr := r.URL.Query().Get("min_magnitude") - sortBy := r.URL.Query().Get("sort") // "magnitude" (default), "gene", "rsid" - offsetStr := r.URL.Query().Get("offset") - limitStr := r.URL.Query().Get("limit") - + // Parse query params into opts + q := r.URL.Query() var minMag float64 - if minMagStr != "" { - minMag, _ = strconv.ParseFloat(minMagStr, 64) + if s := q.Get("min_magnitude"); s != "" { + minMag, _ = strconv.ParseFloat(s, 64) } - offset := 0 - if offsetStr != "" { - offset, _ = strconv.Atoi(offsetStr) + if s := q.Get("offset"); s != "" { + offset, _ = strconv.Atoi(s) } - limit := 100 // default limit - if limitStr != "" { - limit, _ = strconv.Atoi(limitStr) - if limit > 500 { - limit = 500 // max limit - } + limit := 100 + if s := q.Get("limit"); s != "" { + limit, _ = strconv.Atoi(s) } - var rsids []string - if rsidsParam != "" { - rsids = strings.Split(rsidsParam, ",") + if s := q.Get("rsids"); s != "" { + rsids = strings.Split(s, ",") } - var genes []string - if gene != "" { - for _, g := range strings.Split(gene, ",") { - genes = append(genes, strings.TrimSpace(g)) - } - } - - // Find extraction entry - extraction, err := lib.GenomeGetExtraction(sysCtx, dossierID) + result, err := lib.GenomeQuery(dossierID, lib.GenomeQueryOpts{ + Category: q.Get("category"), + Search: q.Get("search"), + Gene: q.Get("gene"), + RSIDs: rsids, + MinMagnitude: minMag, + IncludeHidden: q.Get("include_hidden") == "true", + Sort: q.Get("sort"), + Offset: offset, + Limit: limit, + }) if err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "genome extraction entry not found", - "code": "GENOME_NO_EXTRACTION", - "detail": err.Error(), + "error": err.Error(), }) return } - // Get tiers to query - var tiers []lib.GenomeTier - tierCategories := make(map[string]string) // tierID -> category name - - if category != "" { - // Specific category requested - tier, err := lib.GenomeGetTierByCategory(sysCtx, dossierID, extraction.EntryID, category) - if err == nil { - tiers = append(tiers, *tier) - tierCategories[tier.TierID] = tier.Category - } - } else { - // All tiers - tiers, _ = lib.GenomeGetTiers(sysCtx, dossierID, extraction.EntryID) - for _, t := range tiers { - tierCategories[t.TierID] = t.Category - } - } - - if len(tiers) == 0 { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(GenomeResponse{Matches: []GenomeMatch{}, Returned: 0, Total: 0}) - return - } - - // Get tier IDs - tierIDs := make([]string, len(tiers)) - for i, t := range tiers { - tierIDs[i] = t.TierID - } - - // Query variants - variants, err := lib.GenomeGetVariants(sysCtx, dossierID, tierIDs) - if err != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "failed to retrieve genome variants", - "code": "GENOME_VARIANT_QUERY_FAILED", - "detail": err.Error(), - }) - return - } - - // Filter and collect matches - var matches []GenomeMatch - total := 0 - - for _, v := range variants { - // Filter by rsids if specified - if len(rsids) > 0 { - found := false - for _, r := range rsids { - if r == v.RSID { - found = true - break - } - } - if !found { - continue - } - } - - // Filter by gene(s) - if len(genes) > 0 { - found := false - for _, g := range genes { - if strings.EqualFold(v.Gene, g) { - found = true - break - } - } - if !found { - continue - } - } - - // Filter by search - if search != "" { - searchLower := strings.ToLower(search) - if !strings.Contains(strings.ToLower(v.Gene), searchLower) && - !strings.Contains(strings.ToLower(v.Summary), searchLower) && - !strings.Contains(strings.ToLower(v.Subcategory), searchLower) && - !strings.Contains(strings.ToLower(v.RSID), searchLower) { - continue - } - } - - // Filter by magnitude - if minMag > 0 && v.Magnitude < minMag { - continue - } - if !includeHidden && v.Magnitude > 4.0 { - continue - } - - // Filter out "Bad" repute variants by default (scary/negative results) - if !includeHidden && strings.EqualFold(v.Repute, "bad") { - continue - } - - total++ - // Apply offset and limit - if total <= offset { - continue - } - if len(matches) >= limit { - continue - } - - match := GenomeMatch{ - RSID: v.RSID, - Genotype: v.Genotype, - Gene: v.Gene, - Matched: true, - Category: tierCategories[v.TierID], - Subcategory: v.Subcategory, - } - if v.Magnitude > 0 { - mag := v.Magnitude - match.Magnitude = &mag - } - if v.Repute != "" { - match.Repute = v.Repute - } - if v.Summary != "" { - match.Summary = v.Summary - } - matches = append(matches, match) - } - - // Sort results (default: magnitude descending) - switch sortBy { - case "gene": - sort.Slice(matches, func(i, j int) bool { - return matches[i].Gene < matches[j].Gene - }) - case "rsid": - sort.Slice(matches, func(i, j int) bool { - return matches[i].RSID < matches[j].RSID - }) - default: // "magnitude" or empty - sort by magnitude descending - sort.Slice(matches, func(i, j int) bool { - mi, mj := float64(0), float64(0) - if matches[i].Magnitude != nil { - mi = *matches[i].Magnitude - } - if matches[j].Magnitude != nil { - mj = *matches[j].Magnitude - } - return mi > mj - }) - } - - resp := GenomeResponse{ - Matches: matches, - Returned: len(matches), - Total: total, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + json.NewEncoder(w).Encode(result) } diff --git a/lib/types.go b/lib/types.go index 7f01261..b532d79 100644 --- a/lib/types.go +++ b/lib/types.go @@ -72,51 +72,7 @@ var GenomeTierFromString = map[string]int{ "other": GenomeTierOther, } -// CategoryFromString converts LLM triage output to category enum -var CategoryFromString = map[string]int{ - "imaging": CategoryImaging, - "slice": CategoryImaging, - "series": CategoryImaging, - "study": CategoryImaging, - "document": CategoryDocument, - "radiology_report": CategoryDocument, - "ultrasound": CategoryDocument, - "other": CategoryDocument, - "lab": CategoryLab, - "lab_report": CategoryLab, - "genome": CategoryGenome, - "genome_tier": CategoryGenome, - "rsid": CategoryGenome, - "variant": CategoryGenome, - "upload": CategoryUpload, - "consultation": CategoryConsultation, - "diagnosis": CategoryDiagnosis, - "vital": CategoryVital, - "exercise": CategoryExercise, - "medication": CategoryMedication, - "supplement": CategorySupplement, - "nutrition": CategoryNutrition, - "fertility": CategoryFertility, - "symptom": CategorySymptom, - "note": CategoryNote, - "history": CategoryHistory, - "family_history": CategoryFamilyHistory, - "surgery": CategorySurgery, - "hospitalization": CategoryHospital, - "birth": CategoryBirth, - "device": CategoryDevice, - "therapy": CategoryTherapy, - "assessment": CategoryAssessment, - "provider": CategoryProvider, - "question": CategoryQuestion, -} - -// CategoryKey returns the translation key for a category (e.g. "category003") -func CategoryKey(cat int) string { - return fmt.Sprintf("category%03d", cat) -} - -// categoryNames maps category ints back to their string names +// categoryNames maps category ints to their English names (source of truth) var categoryNames = map[int]string{ CategoryImaging: "imaging", CategoryDocument: "document", @@ -145,6 +101,22 @@ var categoryNames = map[int]string{ CategoryQuestion: "question", } +// CategoryFromString converts category names to ints (generated from categoryNames) +var CategoryFromString map[string]int + +func init() { + // Generate reverse map from categoryNames (single source of truth) + CategoryFromString = make(map[string]int, len(categoryNames)) + for catInt, name := range categoryNames { + CategoryFromString[name] = catInt + } +} + +// CategoryKey returns the translation key for a category (e.g. "category003") +func CategoryKey(cat int) string { + return fmt.Sprintf("category%03d", cat) +} + // CategoryTypes maps category names to their valid type values var CategoryTypes = map[string][]string{ "imaging": {"study", "series", "slice"}, diff --git a/lib/v2.go b/lib/v2.go index 7e348c8..af42e89 100644 --- a/lib/v2.go +++ b/lib/v2.go @@ -9,6 +9,7 @@ import ( "image/png" "os" "path/filepath" + "sort" "strings" "time" ) @@ -1175,6 +1176,216 @@ func GenomeGetVariantsByTier(ctx *AccessContext, dossierID, tierID string) ([]Ge return variants, nil } +// GenomeMatch represents a single genome query result +type GenomeMatch struct { + RSID string `json:"rsid"` + Genotype string `json:"genotype"` + Gene string `json:"gene,omitempty"` + Magnitude *float64 `json:"magnitude,omitempty"` + Repute string `json:"repute,omitempty"` + Summary string `json:"summary,omitempty"` + Category string `json:"category,omitempty"` + Subcategory string `json:"subcategory,omitempty"` +} + +// GenomeQueryResult is the response from GenomeQuery +type GenomeQueryResult struct { + Matches []GenomeMatch `json:"matches"` + Returned int `json:"returned"` + Total int `json:"total"` +} + +// GenomeQueryOpts are the filter/sort/pagination options for GenomeQuery +type GenomeQueryOpts struct { + Category string + Search string + Gene string // comma-separated + RSIDs []string + MinMagnitude float64 + IncludeHidden bool + Sort string // "magnitude" (default), "gene", "rsid" + Offset int + Limit int +} + +// GenomeQuery queries genome variants for a dossier with filtering, sorting, and pagination. +func GenomeQuery(dossierID string, opts GenomeQueryOpts) (*GenomeQueryResult, error) { + sysCtx := &AccessContext{IsSystem: true} + + extraction, err := GenomeGetExtraction(sysCtx, dossierID) + if err != nil { + return nil, fmt.Errorf("GENOME_NO_EXTRACTION: %w", err) + } + + // Get tiers + var tiers []GenomeTier + tierCategories := make(map[string]string) + + if opts.Category != "" { + tier, err := GenomeGetTierByCategory(sysCtx, dossierID, extraction.EntryID, opts.Category) + if err == nil { + tiers = append(tiers, *tier) + tierCategories[tier.TierID] = tier.Category + } + } else { + tiers, _ = GenomeGetTiers(sysCtx, dossierID, extraction.EntryID) + for _, t := range tiers { + tierCategories[t.TierID] = t.Category + } + } + + if len(tiers) == 0 { + return &GenomeQueryResult{Matches: []GenomeMatch{}}, nil + } + + tierIDs := make([]string, len(tiers)) + for i, t := range tiers { + tierIDs[i] = t.TierID + } + + variants, err := GenomeGetVariants(sysCtx, dossierID, tierIDs) + if err != nil { + return nil, fmt.Errorf("GENOME_VARIANT_QUERY_FAILED: %w", err) + } + + // Parse genes + var genes []string + if opts.Gene != "" { + for _, g := range strings.Split(opts.Gene, ",") { + genes = append(genes, strings.TrimSpace(g)) + } + } + + limit := opts.Limit + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + + // Filter and collect + var matches []GenomeMatch + total := 0 + + for _, v := range variants { + if len(opts.RSIDs) > 0 { + found := false + for _, r := range opts.RSIDs { + if r == v.RSID { + found = true + break + } + } + if !found { + continue + } + } + + if len(genes) > 0 { + found := false + for _, g := range genes { + if strings.EqualFold(v.Gene, g) { + found = true + break + } + } + if !found { + continue + } + } + + if opts.Search != "" { + sl := strings.ToLower(opts.Search) + if !strings.Contains(strings.ToLower(v.Gene), sl) && + !strings.Contains(strings.ToLower(v.Summary), sl) && + !strings.Contains(strings.ToLower(v.Subcategory), sl) && + !strings.Contains(strings.ToLower(v.RSID), sl) { + continue + } + } + + if opts.MinMagnitude > 0 && v.Magnitude < opts.MinMagnitude { + continue + } + + // Determine if variant would be hidden + isHidden := v.Magnitude > 4.0 || strings.EqualFold(v.Repute, "bad") + + // Targeted queries (specific rsIDs or gene) return redacted results + // Broad queries skip hidden variants entirely + targeted := len(opts.RSIDs) > 0 || len(genes) > 0 + + if isHidden && !opts.IncludeHidden && !targeted { + continue + } + + total++ + if total <= opts.Offset { + continue + } + if len(matches) >= limit { + continue + } + + // Redact sensitive fields unless include_hidden is set + redact := isHidden && !opts.IncludeHidden + + match := GenomeMatch{ + RSID: v.RSID, + Gene: v.Gene, + Category: tierCategories[v.TierID], + Subcategory: v.Subcategory, + } + if redact { + match.Genotype = "hidden" + match.Summary = "Sensitive variant hidden. Query with include_hidden=true to reveal." + } else { + match.Genotype = v.Genotype + if v.Summary != "" { + match.Summary = v.Summary + } + } + if v.Magnitude > 0 { + mag := v.Magnitude + match.Magnitude = &mag + } + if v.Repute != "" { + match.Repute = v.Repute + } + matches = append(matches, match) + } + + // Sort + switch opts.Sort { + case "gene": + sort.Slice(matches, func(i, j int) bool { + return matches[i].Gene < matches[j].Gene + }) + case "rsid": + sort.Slice(matches, func(i, j int) bool { + return matches[i].RSID < matches[j].RSID + }) + default: + sort.Slice(matches, func(i, j int) bool { + mi, mj := float64(0), float64(0) + if matches[i].Magnitude != nil { + mi = *matches[i].Magnitude + } + if matches[j].Magnitude != nil { + mj = *matches[j].Magnitude + } + return mi > mj + }) + } + + return &GenomeQueryResult{ + Matches: matches, + Returned: len(matches), + Total: total, + }, nil +} + // --- HELPERS --- func deleteByIDs(table, col string, ids []string) error { diff --git a/portal/mcp_tools.go b/portal/mcp_tools.go index d82e903..e4aaf6b 100644 --- a/portal/mcp_tools.go +++ b/portal/mcp_tools.go @@ -10,6 +10,9 @@ import ( "net/http" "net/url" "strconv" + "strings" + + "inou/lib" ) // MCP Tool Implementations @@ -198,33 +201,23 @@ func mcpGetCategories(accessToken, dossier, typ, category string) (string, error } func mcpQueryGenome(accessToken, dossier, gene, search, category, rsids string, minMag float64, includeHidden bool) (string, error) { - params := map[string]string{"dossier": dossier} - if gene != "" { - params["gene"] = gene - } - if search != "" { - params["search"] = search - } - if category != "" { - params["category"] = category - } + var rsidList []string if rsids != "" { - params["rsids"] = rsids - } - if minMag > 0 { - params["min_magnitude"] = strconv.FormatFloat(minMag, 'f', -1, 64) - } - if includeHidden { - params["include_hidden"] = "true" + rsidList = strings.Split(rsids, ",") } - body, err := mcpAPICall(accessToken, "/api/genome", params) + result, err := lib.GenomeQuery(dossier, lib.GenomeQueryOpts{ + Category: category, + Search: search, + Gene: gene, + RSIDs: rsidList, + MinMagnitude: minMag, + IncludeHidden: includeHidden, + }) if err != nil { return "", err } - var data interface{} - json.Unmarshal(body, &data) - pretty, _ := json.MarshalIndent(data, "", " ") + pretty, _ := json.MarshalIndent(result, "", " ") return string(pretty), nil }