refactor: move genome query to lib, add sensitive variant redaction

- Move GenomeQuery logic from api/api_genome.go to lib/v2.go so MCP
  handler calls lib directly instead of HTTP round-trip (fixes 403 on
  genome queries via Claude.ai MCP - was hitting RBAC table mismatch)
- Generate CategoryFromString from categoryNames in init() (single
  source of truth, removes 9 unused aliases)
- Redact sensitive variants (Bad repute, magnitude >4) in targeted
  queries: genotype/summary replaced with "hidden" + hint to use
  include_hidden=true. Broad queries still suppress entirely.
- API handler is now a thin wrapper parsing query params

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
James 2026-02-09 17:23:25 -05:00
parent e8d0656fc6
commit d2d77d1503
4 changed files with 266 additions and 285 deletions

View File

@ -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)
}

View File

@ -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"},

211
lib/v2.go
View File

@ -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 {

View File

@ -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
}