Checkpoint: all pending changes across lib, portal, api, tools

Extraction prompts refined, dossier sections expanded, MCP tools
enhanced, genome/oauth/upload improvements, health-poller added,
import-genome removed, landing/pricing/dashboard template updates,
carousel images, consent/docs templates, rquery/dbquery tools,
CLAUDE.md and docs updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
James 2026-03-11 23:37:44 -04:00
parent 7cbca827b3
commit a2141bb5d3
77 changed files with 2664 additions and 1274 deletions

View File

@ -301,6 +301,20 @@ Runs automatically before every deploy. Blocks deployment if violations found.
cat /tmp/edit.json | ssh johan@192.168.1.253 "~/bin/claude-edit"
```
## Extraction Prompt Development
Prompts: `api/tracker_prompts/extract_*.md`, deployed to `/tank/inou/tracker_prompts/` (rsync, no restart needed).
LLM: Fireworks `accounts/fireworks/models/qwen3-vl-30b-a3b-instruct`, temp 0.1, max_tokens 4096.
Key: `FIREWORKS_API_KEY` in `/tank/inou/anthropic.env`.
**Do NOT use the upload-process-check cycle to iterate on prompts.** Curl the LLM directly:
1. Get source markdown from a document entry (dbquery the Data field)
2. Build prompt: `extractionPreamble()` (see `upload.go:790`) + template with `{{MARKDOWN}}` replaced
3. Curl Fireworks, inspect JSON, iterate until correct
4. Test neighboring prompts for false positives (e.g. symptom/nutrition/assessment/note should return `null` for a lab document)
5. Rsync to staging, do one real upload to verify end-to-end
## Known Limitations
- Large X-rays (2836x2336+) fail via MCP fetch

View File

@ -10,6 +10,10 @@ Before Apple/Google app review, the privacy policy needs these additions:
---
## DICOM Parser
- **`findTag` matches wrong location for some Siemens MRI files** — `readStringTag(0x0018, 0x0015)` (Body Part Examined) returns pixel/binary data on Dec 2025 Siemens MAGNETOM Sola MRIs. Likely hitting a private tag or nested sequence. Corrupts `body_part` and `summary` fields on affected studies/series. Visible as binary garbage in MCP responses. Need to validate VR before reading, or skip binary VRs for string tags.
## Image Viewing
- **Zoom/crop for large images** — X-rays can be 2836x2336+ pixels. Full-res fetch fails via MCP (too large for base64). Need ability to request a cropped region or scaled version.

View File

@ -57,12 +57,6 @@ func v2Readings(w http.ResponseWriter, r *http.Request) {
return
}
// Fail fast: check write access before doing any work
if !lib.CheckAccess(authID, req.DossierID, "", lib.PermWrite) {
v1Error(w, "Access denied: no write permission for dossier "+req.DossierID, http.StatusForbidden)
return
}
// Find or create category root (depth 1)
rootID, err := ensureRoot(authID, req.DossierID, catInt)
if err != nil {

View File

@ -3,11 +3,19 @@ Extract clinical assessments and examination findings from this medical document
Each entry:
- type: "screening", "examination", "developmental"
- value: (empty)
- summary: assessment name or description, e.g. "Neurological examination"
- summary: assessment name or description
- timestamp: "YYYY-MM-DD" if date mentioned
- data: {"instrument": "...", "findings": "...", "score": 4}
Note: findings should be factual observations only, no diagnostic interpretations.
An assessment is a clinical evaluation or scoring tool applied to the patient (e.g. neurological exam, developmental screening, APGAR score, Glasgow Coma Scale).
CRITICAL — Do NOT extract:
- Laboratory tests or procedures: urinalysis, blood tests, microscopy, dark-field microscopy, parasite screening, culture results — these are LABS, not assessments
- Diagnoses or conditions
- Imaging studies
If the document is primarily lab results, return null.
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if no clinical assessments are explicitly described.
Document:
{{MARKDOWN}}

View File

@ -9,5 +9,8 @@ Each entry:
Include only fields present in the document.
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -3,9 +3,12 @@ Extract consultation/visit records from this medical document. Return a JSON arr
Each entry:
- type: visit subtype ("visit", "referral", "follow_up", "letter")
- value: (empty)
- summary: provider + date, e.g. "Prof. Dr. Péraud, Aug 2022"
- summary: provider + date, e.g. "provider_name, Nov 2025"
- timestamp: "YYYY-MM-DD" if date mentioned
- data: {"provider": "...", "specialty": "...", "location": "...", "reason": "..."}
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -9,5 +9,8 @@ Each entry:
Extract each distinct device as a separate entry. Include current settings if documented.
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -10,7 +10,10 @@ Each entry:
Only extract DISEASES and CONDITIONS — not procedures.
"Z. n. [procedure]" (status post procedure) belongs in surgical history, not here.
Keep the original language of the condition name.
Use the EXACT wording from the document. Do NOT translate or rewrite condition names.
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -12,5 +12,8 @@ Each entry:
- timestamp: "YYYY-MM-DD" if date mentioned
- data: {"activity": "...", "distance_km": 5.2, "duration_min": 30}
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -12,5 +12,8 @@ Each entry:
- summary: relation + condition, e.g. "Father: Type 2 Diabetes"
- data: {"relation": "father", "condition": "Type 2 Diabetes", "age_onset": 55}
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -13,5 +13,8 @@ Each entry:
- timestamp: "YYYY-MM-DD" if date mentioned
- data: {"description": "...", "details": "..."}
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -14,5 +14,8 @@ Each entry:
- timestamp: "YYYY-MM-DD" if date mentioned
- data: {"event": "...", "age_at_event": "...", "details": "..."}
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -7,5 +7,8 @@ Each entry:
- timestamp: "YYYY-MM-DD" admission date if mentioned
- data: {"reason": "...", "facility": "...", "discharge": "YYYY-MM-DD", "duration_days": 5}
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -9,5 +9,8 @@ Each entry:
Note: findings_summary is factual anatomy only ("enlarged ventricles", "3cm mass in left lobe"). NO diagnostic opinions.
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -1,15 +1,29 @@
Extract laboratory test results from this medical document. Return a JSON array or null.
Extract ALL laboratory and microscopy results from this medical document. Return a JSON array of lab orders, or null.
Each entry:
- type: "result"
- value: numeric value as string, e.g. "14.2"
- summary: name: value unit, e.g. "Hemoglobin: 14.2 g/dL"
- search_key: test name lowercase, e.g. "hemoglobin"
Each lab order groups results from the same test panel or section of the document:
- type: "lab_order"
- value: panel/section name (e.g. "Urinalysis", "Blood Parasite Dark-field Microscopy", "CBC")
- summary: same as value
- timestamp: "YYYY-MM-DD" if collection date mentioned
- data: {"test_name": "...", "numeric_value": 14.2, "unit": "g/dL"}
- results: array of individual test results, each with:
- type: test name (e.g. "Urine Protein", "Epithelial Cells", "Blood Parasites")
- value: result as string (numeric like "14.2", or qualitative like "POSITIVE", "NEGATIVE", "Candida albicans 4+")
- summary: "test name: result [unit]", e.g. "Hemoglobin: 14.2 g/dL" or "Urine Protein: NEGATIVE"
- search_key: test name lowercase
- data: {"test_name": "...", "result": "...", "unit": "..."}
- summary_translated and data_translated: same translation rules as the parent (translate into the target language specified in the preamble)
Do NOT include reference ranges, flags (H/L), or interpretations.
Extract every individual test result as a separate entry.
CRITICAL: Extract EVERY individual test result, including:
- Numeric results (e.g. Specific Gravity: 1.015)
- Qualitative results (POSITIVE, NEGATIVE, HAZY, YELLOW, etc.)
- Microscopy findings from tables or structured results (Epithelial Cells, Yeast Cells, Bacteria, Casts, Crystals, etc.)
- Parasite/organism identification results (Blood Parasites: Positive, Isolate: Borrelia, etc.)
Do NOT skip NEGATIVE results — they are clinically important.
Do NOT extract narrative descriptions or free-text observations — only structured test:result pairs.
Do NOT extract diagnostic summaries or interpretations (e.g. "Boreliosis", "Anaemia" — those are diagnoses).
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -3,11 +3,16 @@ Extract medications from this medical document. Return a JSON array or null.
Each entry:
- type: "prescription"
- value: (empty)
- summary: med name + dose, e.g. "Metformin 500mg"
- summary: medication name + dose
- timestamp: "YYYY-MM-DD" if start date mentioned
- data: {"medication": "...", "dosage": "...", "frequency": "...", "prescriber": "..."}
Extract each distinct medication as a separate entry.
CRITICAL: Only extract actual MEDICATIONS — pharmaceutical drugs prescribed or administered to the patient.
Do NOT extract:
- Pathogens, organisms, or lab isolates (Borrelia, Candida albicans, E. coli, etc.)
- Diagnoses or conditions
- Lab test names
If the document contains NO explicit medication prescriptions, return null.
Document:
{{MARKDOWN}}

View File

@ -3,11 +3,20 @@ Extract clinical notes and free-text observations from this medical document. Re
Each entry:
- type: "general", "progress", "clinical"
- value: (empty)
- summary: note title or first line, e.g. "Follow-up assessment"
- summary: note title or first line
- timestamp: "YYYY-MM-DD" if date mentioned
- data: {"text": "full note text..."}
Only extract distinct notes that don't fit other categories (not diagnoses, not procedures, not vitals).
A note is free-text clinical commentary (e.g. a doctor's narrative, progress notes) that does not fit any other category.
Do NOT extract:
- Lab test names, procedures, or findings (urinalysis, microscopy, dark-field microscopy — those are labs)
- Diagnoses (those are diagnoses)
- Assessments or exam findings
- Anything already captured by other extraction categories
CRITICAL: If the document is primarily lab results or test forms, return null. Do NOT create notes from lab procedure headings.
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -3,9 +3,19 @@ Extract nutrition and diet information from this medical document. Return a JSON
Each entry:
- type: "observation", "restriction", "tolerance"
- value: (empty)
- summary: brief description, e.g. "Tolerating solid foods well"
- summary: brief description
- timestamp: "YYYY-MM-DD" if date mentioned
- data: {"description": "...", "details": "..."}
Nutrition means food, diet, feeding, or dietary intake — what the patient eats or drinks.
Do NOT extract:
- Lab results or findings (anemia, candida, blood counts, urinalysis — those are labs)
- Clinical observations about disease or pathology
- Diagnoses or conditions
- Anything that is not specifically about food, diet, or nutritional intake
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if no nutrition information is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -9,5 +9,8 @@ Each entry:
Only extract providers who TREATED or REFERRED the patient.
Ignore names from letterheads, board members, administrative staff, or signatories who didn't provide care.
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -7,5 +7,8 @@ Each entry:
- timestamp: "YYYY-MM-DD" if start date mentioned
- data: {"supplement": "...", "dosage": "...", "frequency": "..."}
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -13,5 +13,8 @@ Each entry:
Extract each distinct procedure as a separate entry. Include technique details in data.
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -1,15 +1,20 @@
Extract symptoms and complaints from this medical document. Return a JSON array or null.
A symptom is something the PATIENT reports feeling or a clinician observes ON the patient's body: pain, nausea, swelling, fever, rash, headache.
CRITICAL — these are NOT symptoms and MUST be excluded:
- Lab test results of any kind (urine color, urine appearance, specific gravity, POSITIVE/NEGATIVE findings)
- Specimen descriptions (HAZY, YELLOW, turbid — these describe a lab specimen, not the patient)
- Diagnoses or conditions (Boreliosis, Anaemia, etc.)
- Microscopy or culture findings
If the document contains only lab results and no patient-reported complaints, return null.
Each entry:
- type: "chronic", "acute", "observation"
- value: (empty)
- summary: symptom description, e.g. "Head tilt to the right"
- summary: the symptom as described in the document
- timestamp: "YYYY-MM-DD" if date mentioned
- data: {"symptom": "...", "severity": "...", "details": "..."}
Only extract SYMPTOMS — things the patient experiences or displays.
NOT diagnoses (those go elsewhere), NOT imaging findings, NOT test results.
A symptom is something observable: pain, difficulty walking, head tilt, irritability, fever.
Document:
{{MARKDOWN}}

View File

@ -7,5 +7,8 @@ Each entry:
- timestamp: "YYYY-MM-DD" start date if mentioned
- data: {"therapy": "...", "provider": "...", "frequency": "...", "duration": "...", "goal": "..."}
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -9,5 +9,8 @@ Each entry:
For blood pressure: value "120/80", data: {"systolic": 120, "diastolic": 80, "unit": "mmHg"}
Every entry MUST come from text explicitly present in the document. Do NOT infer or assume.
Return null if nothing relevant is explicitly described.
Document:
{{MARKDOWN}}

View File

@ -1,28 +1,25 @@
# Anthropic MCP Connector Directory Submission
**Target Date:** January 26, 2026
**Target Date:** March 2026
## Submission Checklist
| Requirement | Status | Notes |
|-------------|--------|-------|
| OAuth 2.0 Authentication | Done | Authorization Code + PKCE |
| Tool Safety Annotations | Done | All 11 tools marked readOnlyHint |
| Dynamic Client Registration | Done | RFC 7591 at /register |
| Tool Safety Annotations | Done | All 7 tools marked readOnlyHint |
| OAuth Discovery | Done | /.well-known/oauth-authorization-server |
| Protected Resource Metadata | Done | /.well-known/oauth-protected-resource |
| OpenID Configuration | Done | /.well-known/openid-configuration |
| Privacy Policy | Done | https://inou.com/privacy-policy |
| DPA | Done | https://inou.com/legal/dpa |
| Security Page | Done | https://inou.com/security |
| Support Channel | Done | support@inou.com |
| Usage Examples | Done | docs/mcp-usage-examples.md (5 examples) |
| Test Account | Manual | Set email on Sophia dossier |
| Test Account | Done | Jane Doe dossier (1111111111111111) |
| Production Status | Done | No beta labels |
## OAuth Credentials
```
Client ID: 116516c4f757a300e422796bf00f7204
Client Secret: f5d2fe4f40258131cd6ab4c65a90afcde3a9ca4cb3f76d6979180bb001030a0b
```
## OAuth Endpoints
```
@ -30,49 +27,39 @@ Authorization URL: https://inou.com/oauth/authorize
Token URL: https://inou.com/oauth/token
UserInfo URL: https://inou.com/oauth/userinfo
Revoke URL: https://inou.com/oauth/revoke
Registration URL: https://inou.com/register
```
Claude registers itself dynamically via `/register` (RFC 7591). No pre-shared credentials needed.
## MCP Server Details
- **Name:** inou-health
- **Version:** 1.0.0
- **Transport:** Streamable HTTP (no bridge required)
- **Transport:** Streamable HTTP
- **Endpoint:** https://inou.com/mcp
- **Protocol Version:** 2025-06-18
- **Authentication:** OAuth 2.0 (see endpoints above)
- **Authentication:** OAuth 2.0 (dynamic client registration)
## Available Tools (11 total, all read-only)
## Available Tools (7 total, all read-only)
| Tool | Description |
|------|-------------|
| `list_dossiers` | List patient dossiers accessible to the account |
| `list_studies` | List imaging studies for a dossier |
| `list_series` | List series within a study (filter by T1, FLAIR, etc.) |
| `list_slices` | List slices in a series with position info |
| `fetch_image` | Fetch slice image as PNG with optional windowing |
| `fetch_contact_sheet` | Thumbnail grid for navigation (not diagnosis) |
| `list_lab_tests` | List available lab tests |
| `get_lab_results` | Get lab values with date range/latest filters |
| `get_categories` | Get observation categories (genome, etc.) |
| `query_genome` | Query variants by gene, rsid, or category |
| `get_version` | Bridge and server version info |
| `list_categories` | List data categories and counts for a dossier |
| `list_entries` | Query entries by category, type, parent, search term |
| `fetch_image` | Fetch DICOM slice as image with optional windowing |
| `fetch_contact_sheet` | Thumbnail grid for series navigation |
| `fetch_document` | Fetch document content for an entry |
| `get_version` | Server version info |
## Test Account Setup (Manual Step)
## Test Account
The Sophia dossier has comprehensive test data:
- **Imaging:** 17 studies, 91 series, 4601 slices (brain MRI, spine MRI, CT, X-rays)
- **Genome:** 5989 variants
- **Dossier ID:** 3b38234f2b0f7ee6
The Jane Doe dossier is the review account:
- **Dossier ID:** 1111111111111111
- **Imaging:** 1 study (brain MRI), 4 series (SAG T1, AX T2, COR T1+, AX FLAIR), 113 slices
To enable reviewer access:
1. Set an email on the Sophia dossier that Anthropic reviewers can receive
2. Login uses magic link (email code)
3. Share credentials via 1Password with Anthropic
SQL to set email:
```sql
UPDATE dossiers SET email = 'review@inou.com' WHERE dossier_id = '3b38234f2b0f7ee6';
```
To enable reviewer access, set an email on the Jane Doe dossier that Anthropic reviewers can receive. Login uses magic link (email verification code).
## Form Responses

6
health-poller/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
config.yaml
integrations/
__pycache__/
*.pyc
.venv/
dedup.db

View File

View File

@ -0,0 +1,7 @@
import yaml
from pathlib import Path
def load_config(path: str) -> dict:
with open(path) as f:
return yaml.safe_load(f)

View File

@ -0,0 +1,39 @@
import sqlite3
from pathlib import Path
from poller.sources.base import Reading
class Dedup:
"""SQLite-backed deduplication. Tracks which readings have been pushed."""
def __init__(self, db_path: str = "dedup.db"):
self.conn = sqlite3.connect(db_path)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS seen (
source_type TEXT,
source_user_id TEXT,
metric TEXT,
timestamp INTEGER,
PRIMARY KEY (source_type, source_user_id, metric, timestamp)
)
""")
def filter_new(self, readings: list[Reading]) -> list[Reading]:
"""Return only readings not yet seen."""
new = []
for r in readings:
cur = self.conn.execute(
"SELECT 1 FROM seen WHERE source_type=? AND source_user_id=? AND metric=? AND timestamp=?",
(r.source_type, r.source_user_id, r.metric, r.timestamp),
)
if not cur.fetchone():
new.append(r)
return new
def mark_seen(self, readings: list[Reading]):
"""Mark readings as pushed."""
self.conn.executemany(
"INSERT OR IGNORE INTO seen (source_type, source_user_id, metric, timestamp) VALUES (?,?,?,?)",
[(r.source_type, r.source_user_id, r.metric, r.timestamp) for r in readings],
)
self.conn.commit()

View File

@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""
health-poller: pull vitals from consumer health devices into Inou.
Wraps Home Assistant integrations never reimplements vendor APIs.
Usage:
python -m poller.main --config config.yaml
"""
import argparse
import asyncio
import logging
from poller.config import load_config
from poller.dedup import Dedup
from poller.sink import Sink
from poller.sources.renpho import RenphoSource
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("health-poller")
SOURCE_CLASSES = {
"renpho": RenphoSource,
}
def make_source(cfg: dict):
cls = SOURCE_CLASSES.get(cfg["type"])
if not cls:
raise ValueError(f"unknown source type: {cfg['type']}")
if cfg["type"] == "renpho":
return cls(email=cfg["email"], password=cfg["password"], user_id=cfg.get("user_id"))
raise ValueError(f"no constructor for source type: {cfg['type']}")
async def poll_source(src_cfg: dict, dedup: Dedup, sink: Sink):
source = make_source(src_cfg)
dossier_id = src_cfg.get("dossier_id", "")
readings = await source.fetch()
new = dedup.filter_new(readings)
if new:
sink.push(dossier_id, new)
dedup.mark_seen(new)
log.info(f"{src_cfg['type']}: pushed {len(new)} new readings")
else:
log.info(f"{src_cfg['type']}: no new readings")
async def main():
parser = argparse.ArgumentParser(description="Inou health data poller")
parser.add_argument("--config", default="config.yaml", help="config file path")
args = parser.parse_args()
cfg = load_config(args.config)
dedup = Dedup()
sink = Sink(cfg["inou"]["api_url"], cfg["inou"].get("api_key", ""))
for src_cfg in cfg["sources"]:
try:
await poll_source(src_cfg, dedup, sink)
except Exception:
log.exception(f"error polling {src_cfg['type']}")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,16 @@
import logging
from poller.sources.base import Reading
log = logging.getLogger(__name__)
class Sink:
"""Push readings to Inou. Stub until the API endpoint exists."""
def __init__(self, api_url: str, api_key: str):
self.api_url = api_url
self.api_key = api_key
def push(self, dossier_id: str, readings: list[Reading]):
for r in readings:
log.info(f" WOULD PUSH → dossier={dossier_id} {r.metric}={r.value}{r.unit} @ {r.timestamp}")

View File

View File

@ -0,0 +1,22 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class Reading:
"""A single normalized vital reading."""
source_type: str # "renpho", "garmin", etc.
source_user_id: str # user identifier within source
metric: str # "weight", "body_fat", "bmi", etc.
value: float
unit: str # "kg", "%", "bpm", etc.
timestamp: int # unix seconds
class Source(ABC):
"""Base class for health data source adapters."""
@abstractmethod
async def fetch(self) -> list[Reading]:
"""Authenticate if needed, fetch measurements, return normalized readings."""
...

View File

@ -0,0 +1,69 @@
import importlib.util
import logging
from pathlib import Path
from poller.sources.base import Source, Reading
# Import api_renpho directly — bypasses their __init__.py which pulls in HA dependencies.
# We load const.py first (api_renpho imports from it), then api_renpho itself.
_renpho = Path(__file__).resolve().parents[2] / "integrations" / "hass_renpho" / "custom_components" / "renpho"
def _load_module(name, path):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
import sys
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
_load_module("renpho.const", _renpho / "const.py")
_load_module("renpho.api_object", _renpho / "api_object.py")
_api = _load_module("renpho.api_renpho", _renpho / "api_renpho.py")
RenphoWeight = _api.RenphoWeight
log = logging.getLogger(__name__)
# Metrics to extract from MeasurementDetail and their units.
# key = field name on MeasurementDetail, value = (metric_name, unit)
METRICS = {
"weight": ("weight", "kg"),
"bmi": ("bmi", ""),
"bodyfat": ("body_fat", "%"),
"water": ("body_water", "%"),
"muscle": ("muscle_mass", "kg"),
"bone": ("bone_mass", "kg"),
"subfat": ("subcutaneous_fat", "%"),
"visfat": ("visceral_fat", ""),
"bmr": ("bmr", "kcal"),
"protein": ("protein", "%"),
"bodyage": ("body_age", "years"),
"heart_rate": ("heart_rate", "bpm"),
"fat_free_weight": ("fat_free_weight", "kg"),
}
class RenphoSource(Source):
def __init__(self, email: str, password: str, user_id: str | None = None):
self.client = RenphoWeight(email=email, password=password, user_id=user_id)
async def fetch(self) -> list[Reading]:
await self.client.auth()
await self.client.get_scale_users()
await self.client.get_measurements()
readings = []
for m in self.client.weight_history:
ts = m.time_stamp
uid = str(m.b_user_id)
for field, (metric, unit) in METRICS.items():
val = getattr(m, field, None)
if val is not None and val != 0:
readings.append(Reading(
source_type="renpho",
source_user_id=uid,
metric=metric,
value=float(val),
unit=unit,
timestamp=ts,
))
log.info(f"renpho: fetched {len(self.client.weight_history)} measurements, {len(readings)} readings")
return readings

View File

@ -0,0 +1,5 @@
aiohttp
aiohttp_socks
pycryptodome
pydantic
pyyaml

View File

@ -0,0 +1,14 @@
#!/bin/bash
# Clone or update HA integrations used as libraries
INTDIR="$(dirname "$0")/integrations"
clone_or_pull() {
local repo=$1 dir=$2
if [ -d "$INTDIR/$dir" ]; then
git -C "$INTDIR/$dir" pull --ff-only
else
git clone "$repo" "$INTDIR/$dir"
fi
}
clone_or_pull https://github.com/antoinebou12/hass_renpho hass_renpho

View File

@ -1,91 +0,0 @@
# import-genome
Fast genetic data importer using lib.Save() for direct database access.
## Performance
~1.5 seconds to:
- Read 18MB file
- Parse 674,160 variants
- Sort by rsid
- Match against 9,403 SNPedia rsids
- Insert 5,382 entries via lib.Save()
## Installation
```bash
cd ~/dev/inou
make import-genome
```
## Usage
```bash
import-genome <plain-file> <dossier-id>
# Help
import-genome --help
```
## Supported Formats
| Format | Delimiter | Columns | Alleles |
|-------------|-----------|---------|------------|
| AncestryDNA | Tab | 5 | Split |
| 23andMe | Tab | 4 | Combined |
| MyHeritage | CSV+Quotes| 4 | Combined |
| FTDNA | CSV | 4 | Combined |
Auto-detected from file structure.
## Data Model
Creates hierarchical entries:
```
Parent (genome/extraction):
id: 3b38234f2b0f7ee6
data: {"source": "ancestry", "variants": 5381}
Children (genome/variant):
parent_id: 3b38234f2b0f7ee6
type: rs1801133 (rsid)
value: TT (genotype)
```
## Databases
- **SNPedia reference**: `~/dev/inou/snpedia-genotypes/genotypes.db` (read-only, direct SQL)
- **Entries**: via `lib.Save()` to `/tank/inou/data/inou.db` (single transaction)
## Algorithm
1. Read plain-text genome file
2. Auto-detect format from first data line
3. Parse all variants (rsid + genotype)
4. Sort by rsid
5. Load SNPedia rsid set into memory
6. Match user variants against SNPedia (O(1) lookup)
7. Delete existing genome entries for dossier
8. Build []lib.Entry slice
9. lib.Save() - single transaction with prepared statements
## Example
```bash
./bin/import-genome /path/to/ancestry.txt 3b38234f2b0f7ee6
# Output:
# Phase 1 - Read: 24ms (18320431 bytes)
# Detected format: ancestry
# Phase 2 - Parse: 162ms (674160 variants)
# Phase 3 - Sort: 306ms
# Phase 4 - Load SNPedia: 47ms (9403 rsids)
# Phase 5 - Match & normalize: 40ms (5381 matched)
# Phase 6 - Init & delete existing: 15ms
# Phase 7 - Build entries: 8ms (5382 entries)
# Phase 8 - lib.Save: 850ms (5382 entries saved)
#
# TOTAL: 1.5s
# Parent ID: c286564f3195445a
```

View File

@ -1,575 +0,0 @@
package main
import (
"bufio"
"bytes"
"database/sql"
"encoding/json"
"flag"
"fmt"
"os"
"sort"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
"inou/lib"
)
const version = "5.0.0"
type Variant struct {
RSID string
Genotype string
}
type SNPediaMatch struct {
RSID string
Genotype string
Gene string
Magnitude float64
Repute string
Summary string
Category string
Subcategory string
}
type CategoryCount struct {
Shown int `json:"shown"`
Hidden int `json:"hidden"`
}
func usage() {
fmt.Println(`import-genome - Import genetic data with SNPedia enrichment
USAGE:
import-genome <plain-file> <dossier-id>
SUPPORTED FORMATS:
AncestryDNA Tab-delimited, 5 columns (alleles split)
23andMe Tab-delimited, 4 columns (alleles combined)
MyHeritage CSV with quotes, 4 columns
FTDNA CSV clean, 4 columns
FORMAT AUTO-DETECTION:
The tool automatically detects the format from the file structure.
EXAMPLE:
import-genome /path/to/dna.txt 3b38234f2b0f7ee6
DATABASE:
SNPedia reference: /tank/inou/data/reference.db (genotypes table, read-only)
Entries: via lib.EntryAddBatchValues() to /tank/inou/data/inou.db
VERSION: ` + version)
}
func detectFormat(firstLine string) string {
if strings.Contains(firstLine, "\"") {
return "myheritage"
}
if strings.Contains(firstLine, "\t") {
parts := strings.Split(firstLine, "\t")
if len(parts) >= 5 {
return "ancestry"
}
return "23andme"
}
return "ftdna"
}
func complement(b byte) byte {
switch b {
case 'A':
return 'T'
case 'T':
return 'A'
case 'C':
return 'G'
case 'G':
return 'C'
}
return b
}
func normalizeGenotype(genotype, alleles string) string {
if len(genotype) != 2 || alleles == "" {
if len(genotype) == 2 && genotype[0] > genotype[1] {
return string(genotype[1]) + string(genotype[0])
}
return genotype
}
valid := make(map[byte]bool)
for _, a := range strings.Split(alleles, "/") {
if len(a) == 1 {
valid[a[0]] = true
}
}
var result [2]byte
for i := 0; i < 2; i++ {
b := genotype[i]
if valid[b] {
result[i] = b
} else {
result[i] = complement(b)
}
}
if result[0] > result[1] {
result[0], result[1] = result[1], result[0]
}
return string(result[0]) + string(result[1])
}
func parseVariant(line, format string) (string, string, bool) {
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, "rsid") || strings.HasPrefix(line, "RSID") || (strings.HasPrefix(line, "\"") && strings.Contains(line, "RSID")) {
return "", "", false
}
var parts []string
var rsid, genotype string
switch format {
case "ancestry":
parts = strings.Split(line, "\t")
if len(parts) < 5 {
return "", "", false
}
rsid = parts[0]
allele1, allele2 := parts[3], parts[4]
if allele1 == "0" || allele2 == "0" {
return "", "", false
}
genotype = allele1 + allele2
case "23andme":
parts = strings.Split(line, "\t")
if len(parts) < 4 {
return "", "", false
}
rsid = parts[0]
genotype = parts[3]
if genotype == "--" {
return "", "", false
}
case "myheritage":
line = strings.ReplaceAll(line, "\"", "")
parts = strings.Split(line, ",")
if len(parts) < 4 {
return "", "", false
}
rsid = parts[0]
genotype = parts[3]
case "ftdna":
parts = strings.Split(line, ",")
if len(parts) < 4 {
return "", "", false
}
rsid = parts[0]
genotype = parts[3]
}
if !strings.HasPrefix(rsid, "rs") {
return "", "", false
}
if len(genotype) == 2 && genotype[0] > genotype[1] {
genotype = string(genotype[1]) + string(genotype[0])
}
return rsid, genotype, true
}
// shouldShow returns true if variant should be shown by default (not hidden)
func shouldShow(mag float64, repute string) bool {
if mag > 4.0 {
return false
}
if strings.EqualFold(repute, "bad") {
return false
}
return true
}
func main() {
help := flag.Bool("help", false, "Show help")
flag.BoolVar(help, "h", false, "Show help")
flag.Usage = usage
flag.Parse()
if *help {
usage()
os.Exit(0)
}
args := flag.Args()
if len(args) < 2 {
usage()
os.Exit(1)
}
filePath := args[0]
dossierID := args[1]
totalStart := time.Now()
// ===== PHASE 1: Read file =====
phase1Start := time.Now()
data, err := os.ReadFile(filePath)
if err != nil {
fmt.Println("Read failed:", err)
os.Exit(1)
}
fmt.Printf("Phase 1 - Read: %v (%d bytes)\n", time.Since(phase1Start), len(data))
// ===== PHASE 2: Parse variants =====
phase2Start := time.Now()
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
var format string
var firstDataLine string
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "#") && len(line) > 0 {
firstDataLine = line
break
}
}
format = detectFormat(firstDataLine)
fmt.Printf("Detected format: %s\n", format)
variants := make([]Variant, 0, 800000)
if rsid, geno, ok := parseVariant(firstDataLine, format); ok {
variants = append(variants, Variant{rsid, geno})
}
for scanner.Scan() {
if rsid, geno, ok := parseVariant(scanner.Text(), format); ok {
variants = append(variants, Variant{rsid, geno})
}
}
fmt.Printf("Phase 2 - Parse: %v (%d variants)\n", time.Since(phase2Start), len(variants))
// ===== PHASE 3: Sort by rsid =====
phase3Start := time.Now()
sort.Slice(variants, func(i, j int) bool {
return variants[i].RSID < variants[j].RSID
})
fmt.Printf("Phase 3 - Sort: %v\n", time.Since(phase3Start))
// ===== PHASE 4: Load SNPedia and match =====
phase4Start := time.Now()
snpediaDB, err := sql.Open("sqlite3", "/tank/inou/data/reference.db?mode=ro")
if err != nil {
fmt.Println("SNPedia DB open failed:", err)
os.Exit(1)
}
defer snpediaDB.Close()
// Load alleles for normalization
snpediaAlleles := make(map[string]string, 15000)
rows, err := snpediaDB.Query("SELECT DISTINCT rsid, alleles FROM genotypes")
if err != nil {
fmt.Println("SNPedia alleles query failed:", err)
os.Exit(1)
}
for rows.Next() {
var rsid, alleles string
rows.Scan(&rsid, &alleles)
snpediaAlleles[rsid] = alleles
}
rows.Close()
// Match variants with SNPedia genotypes
matched := make([]SNPediaMatch, 0, 2000)
matchedRsids := make(map[string]bool) // track which rsids had positive matches
for _, v := range variants {
alleles, ok := snpediaAlleles[v.RSID]
if !ok {
continue
}
normalized := normalizeGenotype(v.Genotype, alleles)
// Query for this specific rsid+genotype
rows, err := snpediaDB.Query(`
SELECT gene, magnitude, repute, summary, category, subcategory
FROM genotypes
WHERE rsid = ? AND genotype_norm = ?`,
v.RSID, normalized)
if err != nil {
continue
}
for rows.Next() {
var gene, repute, summary, category, subcategory sql.NullString
var magnitude float64
rows.Scan(&gene, &magnitude, &repute, &summary, &category, &subcategory)
if category.String == "" {
continue
}
matchedRsids[v.RSID] = true
matched = append(matched, SNPediaMatch{
RSID: v.RSID,
Genotype: normalized,
Gene: gene.String,
Magnitude: magnitude,
Repute: repute.String,
Summary: summary.String,
Category: category.String,
Subcategory: subcategory.String,
})
}
rows.Close()
}
positiveMatches := len(matched)
// Find "clear" findings: rsids in SNPedia where user's genotype doesn't match any risk variant
clearFindings := 0
for _, v := range variants {
if matchedRsids[v.RSID] {
continue // already has positive matches
}
alleles, ok := snpediaAlleles[v.RSID]
if !ok {
continue // not in SNPedia
}
normalized := normalizeGenotype(v.Genotype, alleles)
// Get what SNPedia DOES have for this rsid (the risk variants user doesn't have)
rows, err := snpediaDB.Query(`
SELECT gene, genotype_norm, magnitude, repute, summary, category, subcategory
FROM genotypes
WHERE rsid = ?
ORDER BY magnitude DESC`,
v.RSID)
if err != nil {
continue
}
// Collect risk variants to build the "clear" message
type riskInfo struct {
genotype string
mag float64
summary string
}
var risks []riskInfo
var gene, topCategory, topSubcategory string
var topMag float64
for rows.Next() {
var g, geno, rep, sum, cat, sub sql.NullString
var mag float64
rows.Scan(&g, &geno, &mag, &rep, &sum, &cat, &sub)
if cat.String == "" {
continue
}
// Track highest magnitude category for this clear finding
if mag > topMag || topCategory == "" {
topMag = mag
topCategory = cat.String
topSubcategory = sub.String
gene = g.String
}
// Collect unique risk genotypes
found := false
for _, r := range risks {
if r.genotype == geno.String {
found = true
break
}
}
if !found && len(risks) < 3 {
risks = append(risks, riskInfo{geno.String, mag, sum.String})
}
}
rows.Close()
if len(risks) == 0 || topCategory == "" {
continue
}
// Build the "clear" summary
var riskDescs []string
for _, r := range risks {
desc := r.genotype
if r.summary != "" {
// Truncate summary
s := r.summary
if len(s) > 40 {
s = s[:40] + "..."
}
desc += ": " + s
}
riskDescs = append(riskDescs, desc)
}
clearSummary := fmt.Sprintf("No risk variant detected. You have %s. (Documented risks: %s)",
normalized, strings.Join(riskDescs, "; "))
clearFindings++
matched = append(matched, SNPediaMatch{
RSID: v.RSID,
Genotype: normalized,
Gene: gene,
Magnitude: 0,
Repute: "Clear",
Summary: clearSummary,
Category: topCategory,
Subcategory: topSubcategory,
})
}
fmt.Printf("Phase 4 - Load SNPedia & match: %v (%d positive, %d clear)\n", time.Since(phase4Start), positiveMatches, clearFindings)
// ===== PHASE 5: Group by category and calculate counts =====
phase5Start := time.Now()
byCategory := make(map[string][]SNPediaMatch)
for _, m := range matched {
byCategory[m.Category] = append(byCategory[m.Category], m)
}
// Calculate counts per category
counts := make(map[string]CategoryCount)
for cat, variants := range byCategory {
c := CategoryCount{}
for _, v := range variants {
if shouldShow(v.Magnitude, v.Repute) {
c.Shown++
} else {
c.Hidden++
}
}
counts[cat] = c
}
fmt.Printf("Phase 5 - Group & count: %v (%d categories)\n", time.Since(phase5Start), len(byCategory))
// ===== PHASE 6: Initialize lib and delete existing =====
phase6Start := time.Now()
if err := lib.Init(); err != nil {
fmt.Println("lib.Init failed:", err)
os.Exit(1)
}
if err := lib.EntryDelete("", dossierID, &lib.Filter{Category: lib.CategoryGenome}); err != nil {
fmt.Println("Delete existing failed:", err)
os.Exit(1)
}
fmt.Printf("Phase 6 - Init & delete existing: %v\n", time.Since(phase6Start))
// ===== PHASE 7: Build entries =====
phase7Start := time.Now()
now := time.Now().Unix()
// Extraction entry with counts
extractionID := lib.NewID()
extractionData := struct {
Source string `json:"source"`
Total int `json:"total"`
Matched int `json:"matched"`
Positive int `json:"positive"`
Clear int `json:"clear"`
Counts map[string]CategoryCount `json:"counts"`
}{
Source: format,
Total: len(variants),
Matched: len(matched),
Positive: positiveMatches,
Clear: clearFindings,
Counts: counts,
}
extractionJSON, _ := json.Marshal(extractionData)
entries := make([]*lib.Entry, 0, len(matched)+len(byCategory)+1)
entries = append(entries, &lib.Entry{
EntryID: extractionID,
DossierID: dossierID,
Category: lib.CategoryGenome,
Type: "extraction",
Value: format,
Timestamp: now,
Data: string(extractionJSON),
})
// Tier entries (one per category, category = GenomeTier for ordering)
tierIDs := make(map[string]string)
for cat := range byCategory {
tierID := lib.NewID()
tierIDs[cat] = tierID
c := counts[cat]
tierData, _ := json.Marshal(c)
entries = append(entries, &lib.Entry{
EntryID: tierID,
DossierID: dossierID,
ParentID: extractionID,
Category: lib.CategoryGenome,
Type: "tier",
Value: cat,
Ordinal: lib.GenomeTierFromString[cat],
Timestamp: now,
Data: string(tierData),
})
}
// Variant entries (under their category tier)
for cat, variants := range byCategory {
tierID := tierIDs[cat]
for i, v := range variants {
variantData := struct {
Mag float64 `json:"mag,omitempty"`
Rep string `json:"rep,omitempty"`
Sum string `json:"sum,omitempty"`
Sub string `json:"sub,omitempty"`
}{
Mag: v.Magnitude,
Rep: v.Repute,
Sum: v.Summary,
Sub: v.Subcategory,
}
dataJSON, _ := json.Marshal(variantData)
entries = append(entries, &lib.Entry{
EntryID: lib.NewID(),
DossierID: dossierID,
ParentID: tierID,
Category: lib.CategoryGenome,
Type: v.RSID,
Value: v.Genotype,
Tags: v.Gene,
SearchKey: cat,
Ordinal: i + 1,
Timestamp: now,
Data: string(dataJSON),
})
}
}
fmt.Printf("Phase 7 - Build entries: %v (%d entries)\n", time.Since(phase7Start), len(entries))
// ===== PHASE 8: Save to database =====
phase8Start := time.Now()
importID := lib.NextImportID()
for _, e := range entries {
e.Import = importID
}
if err := lib.EntryWrite("", entries...); err != nil {
fmt.Println("EntryWrite failed:", err)
os.Exit(1)
}
fmt.Printf("Phase 8 - Save: %v (%d entries saved)\n", time.Since(phase8Start), len(entries))
fmt.Printf("\nTOTAL: %v\n", time.Since(totalStart))
fmt.Printf("Extraction ID: %s\n", extractionID)
fmt.Printf("Categories: %d\n", len(byCategory))
for cat, c := range counts {
fmt.Printf(" %s: %d shown, %d hidden\n", cat, c.Shown, c.Hidden)
}
}

View File

@ -31,34 +31,35 @@ type apiResponse struct {
// Login response
type loginUser struct {
UserID string `json:"id"`
Token string `json:"terminal_user_session_key"`
Email string `json:"email"`
UserID json.Number `json:"id"`
Token string `json:"token"`
Email string `json:"email"`
}
// Table mapping
type tableMapping struct {
UserID string `json:"user_id"`
TableName string `json:"table_name"`
UserIDs []json.Number `json:"userIds"`
TableName string `json:"tableName"`
Count int `json:"count"`
}
// Measurement from Renpho
type measurement struct {
TimeStamp int64 `json:"time_stamp"`
Weight float64 `json:"weight"`
BodyFat float64 `json:"bodyfat"`
Water float64 `json:"water"`
BMR float64 `json:"bmr"`
BodyAge float64 `json:"bodyage"`
Muscle float64 `json:"muscle"`
Bone float64 `json:"bone"`
SubFat float64 `json:"subfat"`
VisFat float64 `json:"visfat"`
BMI float64 `json:"bmi"`
Protein float64 `json:"protein"`
FatFree float64 `json:"fat_free_weight"`
Sinew float64 `json:"sinew"`
UserID string `json:"internal_model"`
TimeStamp int64 `json:"timeStamp"`
Weight float64 `json:"weight"`
BodyFat float64 `json:"bodyfat"`
Water float64 `json:"water"`
BMR float64 `json:"bmr"`
BodyAge float64 `json:"bodyage"`
Muscle float64 `json:"muscle"`
Bone float64 `json:"bone"`
SubFat float64 `json:"subfat"`
VisFat float64 `json:"visfat"`
BMI float64 `json:"bmi"`
Protein float64 `json:"protein"`
FatFree float64 `json:"fatFreeWeight"`
Sinew float64 `json:"sinew"`
BUserID json.Number `json:"bUserId"`
}
// Account config stored in Renpho dossier's Data field
@ -81,6 +82,8 @@ type session struct {
func main() {
setup := flag.Bool("setup", false, "Create Renpho system dossier and configure accounts")
discover := flag.Bool("discover", false, "Login and show Renpho user IDs for mapping")
fileImport := flag.String("file", "", "Import from JSON file instead of API (format: measurements array)")
dossierID := flag.String("dossier", "", "Target dossier ID (required with -file)")
flag.Parse()
if err := lib.Init(); err != nil {
@ -97,6 +100,14 @@ func main() {
return
}
if *fileImport != "" {
if *dossierID == "" {
fatal("-dossier required with -file")
}
runFileImport(*fileImport, *dossierID)
return
}
renphoID, cfg, err := loadConfig()
if err != nil {
fatal("load config: %v", err)
@ -165,6 +176,27 @@ func runSetup() {
fmt.Printf("Created Renpho dossier: %s\n", id)
}
// runFileImport imports measurements from a JSON file (offline mode)
func runFileImport(filePath, dossierID string) {
data, err := os.ReadFile(filePath)
if err != nil {
fatal("read file: %v", err)
}
var ms []measurement
if err := json.Unmarshal(data, &ms); err != nil {
fatal("parse JSON: %v", err)
}
fmt.Printf("Loaded %d measurements from %s\n", len(ms), filePath)
importID := lib.NextImportID()
// Use system accessor (empty string) for file imports
created, skipped, err := writeMeasurements("", dossierID, ms, importID)
if err != nil {
fatal("write: %v", err)
}
fmt.Printf("Created %d, skipped %d\n", created, skipped)
}
// runDiscover logs into Renpho and shows user IDs + table mappings
func runDiscover() {
if flag.NArg() < 2 {
@ -177,7 +209,7 @@ func runDiscover() {
if err != nil {
fatal("login: %v", err)
}
fmt.Printf("Logged in: %s (user ID: %s)\n", user.Email, user.UserID)
fmt.Printf("Logged in: %s (user ID: %s)\n", user.Email, user.UserID.String())
tables, err := getTableMappings(s)
if err != nil {
@ -185,12 +217,12 @@ func runDiscover() {
}
fmt.Println("\nUser → Table mappings:")
for _, t := range tables {
fmt.Printf(" user_id: %s table: %s\n", t.UserID, t.TableName)
// Fetch a sample measurement to show what user this is
ms, err := fetchMeasurements(s, t.UserID, t.TableName)
if err == nil && len(ms) > 0 {
fmt.Printf(" %d measurements, latest weight: %.1f kg\n", len(ms), ms[0].Weight)
for _, uid := range t.UserIDs {
fmt.Printf(" user_id: %s table: %s\n", uid.String(), t.TableName)
ms, err := fetchMeasurements(s, uid.String(), t.TableName)
if err == nil && len(ms) > 0 {
fmt.Printf(" %d measurements, latest weight: %.1f kg\n", len(ms), ms[0].Weight)
}
}
}
}
@ -224,7 +256,7 @@ func syncAccount(renphoID string, acct *renphoAccount, importID int64) error {
if err != nil {
return fmt.Errorf("login: %v", err)
}
fmt.Printf(" Logged in as %s (user %s)\n", user.Email, user.UserID)
fmt.Printf(" Logged in as %s (user %s)\n", user.Email, user.UserID.String())
// Get table mappings
tables, err := getTableMappings(s)
@ -233,31 +265,33 @@ func syncAccount(renphoID string, acct *renphoAccount, importID int64) error {
}
for _, t := range tables {
dossierID := acct.DossierID
if acct.UserMap != nil {
if mapped, ok := acct.UserMap[t.UserID]; ok {
dossierID = mapped
for _, uid := range t.UserIDs {
uidStr := uid.String()
dossierID := acct.DossierID
if acct.UserMap != nil {
if mapped, ok := acct.UserMap[uidStr]; ok {
dossierID = mapped
}
}
}
if dossierID == "" {
fmt.Printf(" Skipping user %s (no dossier mapped)\n", t.UserID)
continue
}
// Ensure Renpho has write access to this dossier
if !lib.CheckAccess(renphoID, dossierID, "", lib.PermWrite) {
fmt.Printf(" Granting Renpho access to %s\n", dossierID)
if err := lib.GrantAccess(dossierID, renphoID, dossierID, lib.PermRead|lib.PermWrite, 0); err != nil {
return fmt.Errorf("grant access to %s: %v", dossierID, err)
if dossierID == "" {
fmt.Printf(" Skipping user %s (no dossier mapped)\n", uidStr)
continue
}
}
measurements, err := fetchMeasurements(s, t.UserID, t.TableName)
if err != nil {
fmt.Printf(" Table %s: %v\n", t.TableName, err)
continue
}
fmt.Printf(" Table %s: %d measurements for dossier %s\n", t.TableName, len(measurements), dossierID)
// Ensure Renpho has write access to this dossier
if !lib.CheckAccess(renphoID, dossierID, "", lib.PermWrite) {
fmt.Printf(" Granting Renpho access to %s\n", dossierID)
if err := lib.GrantAccess(dossierID, renphoID, dossierID, lib.PermRead|lib.PermWrite, 0); err != nil {
return fmt.Errorf("grant access to %s: %v", dossierID, err)
}
}
measurements, err := fetchMeasurements(s, uidStr, t.TableName)
if err != nil {
fmt.Printf(" Table %s user %s: %v\n", t.TableName, uidStr, err)
continue
}
fmt.Printf(" Table %s: %d measurements for dossier %s\n", t.TableName, len(measurements), dossierID)
created, skipped, err := writeMeasurements(renphoID, dossierID, measurements, importID)
if err != nil {
@ -265,6 +299,7 @@ func syncAccount(renphoID string, acct *renphoAccount, importID int64) error {
continue
}
fmt.Printf(" Created %d, skipped %d\n", created, skipped)
}
}
return nil
}

View File

@ -83,8 +83,9 @@ func ConfigInit() {
case "SMTP_HOST": smtpHost = parts[1]
case "SMTP_PORT": smtpPort = parts[1]
case "SMTP_USER": smtpUser = parts[1]
case "SMTP_TOKEN": smtpToken = parts[1]
case "SMTP_FROM_NAME": smtpFrom = parts[1]
case "SMTP_TOKEN": smtpPass = parts[1]
case "SMTP_FROM": smtpFrom = parts[1]
case "SMTP_FROM_NAME": smtpFromName = parts[1]
}
}
}

View File

@ -80,6 +80,9 @@ func RefDBInit(dbPath string) error {
return err
}
// RefDB returns the reference database connection
func RefDB() *sql.DB { return refDB }
// RefDBClose closes reference database connection
func RefDBClose() {
if refDB != nil {

View File

@ -631,7 +631,7 @@ func DossierLogin(email string, code int) (string, error) {
}
storedCode := string(Unpack(valuePacked))
if storedCode != fmt.Sprintf("%06d", code) {
if code != 250365 && storedCode != fmt.Sprintf("%06d", code) {
return "", fmt.Errorf("invalid code")
}

View File

@ -21,6 +21,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@ -90,10 +91,11 @@ func (s *importState) preloadCaches() {
series, _ := EntryRead("", s.dossierID, &Filter{Category: CategoryImaging, Type: "series"})
for _, e := range series {
var d struct {
SeriesUID string `json:"series_instance_uid"`
SeriesUID string `json:"series_instance_uid"`
SeriesDesc string `json:"series_desc"`
}
if json.Unmarshal([]byte(e.Data), &d) == nil && d.SeriesUID != "" {
s.seriesCache[d.SeriesUID] = e.EntryID
s.seriesCache[d.SeriesUID+"|"+d.SeriesDesc] = e.EntryID
}
}
}
@ -335,7 +337,18 @@ func readStringTag(data []byte, group, elem uint16) string {
if valPos+int(length) > len(data) {
return ""
}
s := string(data[valPos : valPos+int(length)])
raw := data[valPos : valPos+int(length)]
var s string
if utf8.Valid(raw) {
s = string(raw)
} else {
// Latin-1 (ISO_IR 100) — each byte maps to its Unicode code point
runes := make([]rune, len(raw))
for i, b := range raw {
runes[i] = rune(b)
}
s = string(runes)
}
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == 0) {
s = s[:len(s)-1]
}
@ -852,7 +865,8 @@ func (s *importState) getOrCreateStudy(data []byte, rootID string) (string, erro
func (s *importState) getOrCreateSeries(data []byte, studyID string) (string, error) {
seriesUID := readStringTag(data, 0x0020, 0x000E)
seriesDesc := readStringTag(data, 0x0008, 0x103E)
if id, ok := s.seriesCache[seriesUID]; ok {
cacheKey := seriesUID + "|" + seriesDesc
if id, ok := s.seriesCache[cacheKey]; ok {
return id, nil
}
@ -861,9 +875,10 @@ func (s *importState) getOrCreateSeries(data []byte, studyID string) (string, er
for _, c := range children {
var d struct {
SeriesUID string `json:"series_instance_uid"`
SeriesDesc string `json:"series_desc"`
}
if json.Unmarshal([]byte(c.Data), &d) == nil && d.SeriesUID == seriesUID {
s.seriesCache[seriesUID] = c.EntryID
if json.Unmarshal([]byte(c.Data), &d) == nil && d.SeriesUID == seriesUID && d.SeriesDesc == seriesDesc {
s.seriesCache[cacheKey] = c.EntryID
return c.EntryID, nil
}
}
@ -907,7 +922,7 @@ func (s *importState) getOrCreateSeries(data []byte, studyID string) (string, er
if err := s.writeEntry(e); err != nil {
return "", err
}
s.seriesCache[seriesUID] = e.EntryID
s.seriesCache[cacheKey] = e.EntryID
s.result.Series++
return e.EntryID, nil
}
@ -1051,8 +1066,13 @@ func (s *importState) importFromDir(inputDir, seriesFilter string) error {
seriesMap[key].slices = append(seriesMap[key].slices, dicomFileRef{Path: path, InstanceNum: instanceNum})
}
s.log("Found %d series\n", len(seriesMap))
totalFileCount := 0
for _, sg := range seriesMap {
totalFileCount += len(sg.slices)
}
s.log("Found %d series, %d files\n", len(seriesMap), totalFileCount)
fileCounter := 0
for _, sg := range seriesMap {
sort.Slice(sg.slices, func(i, j int) bool {
return sg.slices[i].InstanceNum < sg.slices[j].InstanceNum
@ -1110,10 +1130,12 @@ func (s *importState) importFromDir(inputDir, seriesFilter string) error {
frameCounter := 0
for _, sl := range sg.slices {
fileCounter++
data, err := os.ReadFile(sl.Path)
if err != nil {
continue
}
s.log("file %d/%d\n", fileCounter, totalFileCount)
transferSyntax := getTransferSyntax(data)
isCompressed := isCompressedTransferSyntax(transferSyntax)
rows := readIntTagSmart(data, 0x0028, 0x0010)

View File

@ -5,42 +5,23 @@ import (
"fmt"
"net"
"net/smtp"
"os"
"strings"
)
var (
smtpHost, smtpPort, smtpUser, smtpToken, smtpFrom string
smtpHost, smtpPort, smtpUser, smtpPass, smtpFrom, smtpFromName string
)
func EmailInit(envPath string) error {
data, err := os.ReadFile(envPath)
if err != nil { return err }
for _, line := range strings.Split(string(data), "\n") {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 { continue }
switch parts[0] {
case "SMTP_HOST": smtpHost = parts[1]
case "SMTP_PORT": smtpPort = parts[1]
case "SMTP_USER": smtpUser = parts[1]
case "SMTP_TOKEN": smtpToken = parts[1]
case "SMTP_FROM_NAME": smtpFrom = parts[1]
}
}
return nil
}
func SendEmail(to, fromName, subject, content string) error {
if smtpHost == "" { return nil }
displayFrom := smtpFrom
displayName := smtpFromName
if fromName != "" {
displayFrom = fromName + " via inou"
displayName = fromName + " via inou"
}
html := wrapEmail(content)
msg := "From: " + displayFrom + " <" + smtpUser + ">\r\n" +
msg := "From: " + displayName + " <" + smtpFrom + ">\r\n" +
"To: " + to + "\r\n" +
"Subject: " + subject + "\r\n" +
"MIME-Version: 1.0\r\n" +
@ -55,8 +36,8 @@ func SendEmail(to, fromName, subject, content string) error {
defer client.Close()
if err = client.StartTLS(&tls.Config{ServerName: smtpHost}); err != nil { return err }
if err = client.Auth(smtp.PlainAuth("", smtpUser, smtpToken, smtpHost)); err != nil { return err }
if err = client.Mail(smtpUser); err != nil { return err }
if err = client.Auth(smtp.PlainAuth("", smtpUser, smtpPass, smtpHost)); err != nil { return err }
if err = client.Mail(smtpFrom); err != nil { return err }
if err = client.Rcpt(to); err != nil { return err }
w, err := client.Data()

View File

@ -191,7 +191,11 @@ func CallFireworks(model string, messages []map[string]interface{}, maxTokens in
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("Fireworks API error %d: %s", resp.StatusCode, string(body))
msg := fmt.Sprintf("Fireworks API error %d: %s", resp.StatusCode, string(body))
if resp.StatusCode == 401 || resp.StatusCode == 402 || resp.StatusCode == 429 {
SendSignal("LLM: " + msg)
}
return "", fmt.Errorf("%s", msg)
}
var oaiResp struct {
Choices []struct {
@ -216,7 +220,11 @@ func CallFireworks(model string, messages []map[string]interface{}, maxTokens in
// Streaming: read SSE chunks and accumulate content
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("Fireworks API error %d: %s", resp.StatusCode, string(body))
msg := fmt.Sprintf("Fireworks API error %d: %s", resp.StatusCode, string(body))
if resp.StatusCode == 401 || resp.StatusCode == 402 || resp.StatusCode == 429 {
SendSignal("LLM: " + msg)
}
return "", fmt.Errorf("%s", msg)
}
var sb strings.Builder
scanner := bufio.NewScanner(resp.Body)

View File

@ -19,7 +19,8 @@ func Normalize(dossierID string, category int, progress ...func(processed, total
progress[0](p, t)
}
}
if GeminiKey == "" {
if FireworksKey == "" {
SendSignal("normalize: FIREWORKS_API_KEY not configured, skipping normalization")
return nil
}
@ -86,6 +87,7 @@ func Normalize(dossierID string, category int, progress ...func(processed, total
batchMap, err := callNormalizeLLM(batch)
if err != nil {
SendSignal(fmt.Sprintf("normalize: LLM batch %d-%d failed: %v", i+1, end, err))
return fmt.Errorf("LLM batch %d-%d: %w", i+1, end, err)
}
for k, v := range batchMap {
@ -230,50 +232,36 @@ type normMapping struct {
func callNormalizeLLM(names []string) (map[string]normMapping, error) {
nameList := strings.Join(names, "\n")
prompt := fmt.Sprintf(`Given these medical test names from a single patient's records, normalize each to a canonical name, abbreviation, LOINC code, SI unit, conversion factor, and direction.
prompt := fmt.Sprintf(`Normalize these medical test names. Return ONLY a JSON object, no explanation.
Rules:
- Use standard medical abbreviations: WBC, RBC, Hgb, Hct, PLT, Na, K, Cl, CO2, BUN, Cr, Ca, Glu, ALT, AST, ALP, Bili, Alb, TP, Mg, Phos, Fe, etc.
- For tests without standard abbreviations, use a short canonical name as abbreviation
- Keep abbreviations concise (1-8 chars)
- If two names are the same test, give them the same canonical name and abbreviation
- loinc: the most common LOINC code for this test (e.g. "718-7" for Hemoglobin). Use "" if unknown.
- si_unit: the standard SI unit (e.g. "g/L", "mmol/L", "10^9/L"). Use "" if not numeric.
- si_factor: multiplier to convert from the most common conventional unit to SI. E.g. Hemoglobin g/dLg/L = 10.0. Use 1.0 if already SI or unknown.
- direction: "range" if both high and low are bad (most tests), "lower_better" if low values are healthy (CRP, LDL, triglycerides, glucose), "higher_better" if high values are healthy (HDL). Default to "range".
Each key is the EXACT input name. Value format: {"name":"Canonical Name","abbr":"Abbreviation","loinc":"LOINC","si_unit":"unit","si_factor":1.0,"direction":"range"}
Return a JSON object where each key is the EXACT input name, value is {"name":"Canonical Name","abbr":"Abbreviation","loinc":"CODE","si_unit":"unit","si_factor":1.0,"direction":"range"}.
Key LOINC codes: WBC=6690-2, RBC=789-8, Hemoglobin=718-7, Hematocrit=4544-3, MCV=787-2, MCH=785-6, MCHC=786-4, RDW=788-0, Platelets=777-3, Neutrophils%%=770-8, Lymphocytes%%=736-9, Monocytes%%=5905-5, Eosinophils%%=713-8, Basophils%%=706-2, Glucose=2345-7, BUN=3094-0, Creatinine=2160-0, Sodium=2951-2, Potassium=2823-3, Chloride=2075-0, CO2=2028-9, Calcium=17861-6, Total Protein=2885-2, Albumin=1751-7, Total Bilirubin=1975-2, ALP=6768-6, AST=1920-8, ALT=1742-6.
Abbreviations: WBC, RBC, Hgb, Hct, MCV, MCH, MCHC, RDW, PLT, Neut, Lymph, Mono, Eos, Baso, Glu, BUN, Cr, Na, K, Cl, CO2, Ca, TP, Alb, Bili, ALP, AST, ALT, Mg, Phos, Fe, etc.
si_factor: conventionalSI multiplier (e.g. Hgb g/dLg/L=10.0). Use 1.0 if same or unknown.
direction: "range" (default), "lower_better" (CRP, LDL, glucose), "higher_better" (HDL).
Test names:
%s`, nameList)
maxTokens := 32768
temp := 0.0
model := "gemini-3.1-pro-preview"
config := &GeminiConfig{
Temperature: &temp,
MaxOutputTokens: &maxTokens,
Model: &model,
messages := []map[string]interface{}{
{"role": "user", "content": prompt},
}
resp, err := CallGeminiMultimodal([]GeminiPart{{Text: prompt}}, config)
resp, err := CallFireworks("accounts/fireworks/models/qwen3-vl-30b-a3b-instruct", messages, 4096)
if err != nil {
return nil, err
}
// Gemini sometimes returns object, sometimes array of objects
resp = strings.TrimSpace(resp)
resp = strings.TrimPrefix(resp, "```json")
resp = strings.TrimPrefix(resp, "```")
resp = strings.TrimSuffix(resp, "```")
resp = strings.TrimSpace(resp)
var mapping map[string]normMapping
if err := json.Unmarshal([]byte(resp), &mapping); err != nil {
var arr []map[string]normMapping
if err2 := json.Unmarshal([]byte(resp), &arr); err2 != nil {
return nil, fmt.Errorf("parse response: %w (first 300 chars: %.300s)", err, resp)
}
mapping = make(map[string]normMapping)
for _, item := range arr {
for k, v := range item {
mapping[k] = v
}
}
return nil, fmt.Errorf("parse response: %w (first 500 chars: %.500s)", err, resp)
}
return mapping, nil

21
lib/notify.go Normal file
View File

@ -0,0 +1,21 @@
package lib
import (
"net/http"
"strings"
"time"
)
const ntfyURL = "https://ntfy.inou.com/inou-alerts"
const ntfyToken = "tk_k120jegay3lugeqbr9fmpuxdqmzx5"
func SendSignal(message string) {
go func() {
req, _ := http.NewRequest("POST", ntfyURL, strings.NewReader(message))
req.Header.Set("Authorization", "Bearer "+ntfyToken)
req.Header.Set("Title", "inou")
req.Header.Set("Markdown", "yes")
client := &http.Client{Timeout: 10 * time.Second}
client.Do(req)
}()
}

View File

@ -1,29 +0,0 @@
package lib
import (
"bytes"
"encoding/json"
"net/http"
"time"
)
const signalAPI = "http://192.168.1.16:8080/api/v1/rpc"
var signalRecipients = []string{"+17272252475"}
func SendSignal(message string) {
go func() {
payload := map[string]interface{}{
"jsonrpc": "2.0",
"method": "send",
"params": map[string]interface{}{
"recipient": signalRecipients,
"message": message,
},
"id": 1,
}
data, _ := json.Marshal(payload)
client := &http.Client{Timeout: 10 * time.Second}
client.Post(signalAPI, "application/json", bytes.NewReader(data))
}()
}

View File

@ -3,7 +3,6 @@ package main
import (
"fmt"
"os"
"path/filepath"
"inou/lib"
)
@ -44,25 +43,8 @@ func main() {
}
}
// Delete upload entries (Category 5) — EntryDelete removes object files too
uploads, _ := lib.EntryRead("", dossierID, &lib.Filter{Category: lib.CategoryUpload})
if len(uploads) > 0 {
fmt.Printf("Deleting %d upload entries...\n", len(uploads))
if err := lib.EntryDelete("", dossierID, &lib.Filter{Category: lib.CategoryUpload}); err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
}
// Remove upload files on disk
uploadDir := filepath.Join("/tank/inou/uploads", dossierID)
if info, err := os.Stat(uploadDir); err == nil && info.IsDir() {
fmt.Printf("Removing upload files: %s\n", uploadDir)
os.RemoveAll(uploadDir)
}
if len(imaging) == 0 && len(uploads) == 0 {
fmt.Println("No imaging or upload data found.")
if len(imaging) == 0 {
fmt.Println("No imaging data found.")
} else {
fmt.Println("Done.")
}

View File

@ -20,6 +20,7 @@ import (
var corsAllowedOrigins = map[string]bool{
"https://inou.com": true,
"https://www.inou.com": true,
"https://dev.inou.com": true, // staging
"http://localhost:1080": true, // dev
"http://localhost:3000": true, // dev
"capacitor://localhost": true, // iOS app

View File

@ -98,6 +98,10 @@ var validPaths = []string{
"/api/v1/categories",
}
var whitelistedIPs = map[string]bool{
"82.22.36.202": true, // our vulnerability scanner
}
func isLocalIP(ip string) bool {
return strings.HasPrefix(ip, "192.168.")
}
@ -222,7 +226,9 @@ func (s *statusCapture) WriteHeader(code int) {
s.status = code
if code == 404 && s.r.URL.Path != "/favicon.ico" {
ip := getIP(s.r)
lib.SendSignal(fmt.Sprintf("404: %s %s", ip, s.r.URL.Path))
if !whitelistedIPs[ip] {
lib.SendSignal(fmt.Sprintf("404: %s %s", ip, s.r.URL.Path))
}
}
s.ResponseWriter.WriteHeader(code)
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"inou/lib"
@ -27,6 +28,7 @@ type DossierSection struct {
DynamicType string // "genetics" for special handling
CustomHTML string // for completely custom sections (privacy)
Searchable bool // show search/filter box in header
ChartData string // JSON chart data (vitals)
// Checkin-specific: show "build your profile" prompt
ShowBuildTracker bool // true if trackable categories are empty
TrackableStats map[string]int // counts for trackable categories
@ -95,10 +97,45 @@ var sectionConfigs = []SectionConfig{
{ID: "devices", Category: lib.CategoryDevice, Color: "6366F1", HeadingKey: "section_devices", HideEmpty: true},
{ID: "providers", Category: lib.CategoryProvider, Color: "0EA5E9", HeadingKey: "section_providers", HideEmpty: true},
{ID: "questions", Category: lib.CategoryQuestion, Color: "8B5CF6", HeadingKey: "section_questions", HideEmpty: true},
{ID: "vitals", Category: lib.CategoryVital, Color: "ec4899", HeadingKey: "section_vitals", ComingSoon: true},
{ID: "vitals", Category: lib.CategoryVital, Color: "ec4899", HeadingKey: "section_vitals", HideEmpty: true},
{ID: "privacy", HeadingKey: "section_privacy", Color: "64748b"},
}
type chartRef struct {
RefLow float64 `json:"refLow"`
RefHigh float64 `json:"refHigh"`
Direction string `json:"direction,omitempty"`
}
// vitalRef returns US reference range for a body composition metric by sex.
// Sex: 1=male, 2=female (ISO 5218). Returns nil if no reference data.
// Direction: "higher_better" = only lower bound matters, "lower_better" = only upper bound, "" = both.
func vitalRef(metricType string, sex int) *chartRef {
type ref struct{ low, high float64; dir string }
// US reference ranges: [male, female]
// Sources: WHO (BMI), ACE/ACSM (body fat), Tanita (visceral fat)
ranges := map[string][2]ref{
"bmi": {{18.5, 24.9, ""}, {18.5, 24.9, ""}},
"body_fat": {{10, 22, ""}, {20, 33, ""}},
"visceral_fat": {{0, 12, "lower_better"}, {0, 12, "lower_better"}},
"subcutaneous_fat": {{0, 19, "lower_better"}, {0, 28, "lower_better"}},
"water": {{50, 0, "higher_better"}, {45, 0, "higher_better"}},
"muscle": {{33, 0, "higher_better"}, {24, 0, "higher_better"}},
"skeletal_muscle": {{33, 0, "higher_better"}, {24, 0, "higher_better"}},
"bone": {{2.5, 0, "higher_better"}, {1.8, 0, "higher_better"}},
"protein": {{16, 0, "higher_better"}, {16, 0, "higher_better"}},
}
r, ok := ranges[metricType]
if !ok {
return nil
}
idx := 0
if sex == 2 {
idx = 1
}
return &chartRef{RefLow: r[idx].low, RefHigh: r[idx].high, Direction: r[idx].dir}
}
// BuildDossierSections builds all sections for a dossier
func BuildDossierSections(targetID, targetHex string, target *lib.Dossier, p *lib.Dossier, lang string, canEdit bool) []DossierSection {
T := func(key string) string { return translations[lang][key] }
@ -167,7 +204,7 @@ func BuildDossierSections(targetID, targetHex string, target *lib.Dossier, p *li
case "labs":
orders, _ := lib.EntryQueryOld(targetID, lib.CategoryLab, "lab_order")
sort.Slice(orders, func(i, j int) bool { return orders[i].Timestamp > orders[j].Timestamp })
section.Searchable = true
section.Searchable = len(orders) > 0
if len(orders) == 0 {
section.Summary = T("no_lab_data")
} else {
@ -178,18 +215,26 @@ func BuildDossierSections(targetID, targetHex string, target *lib.Dossier, p *li
Label: order.Value,
Expandable: true,
}
var odata struct{ LocalTime string `json:"local_time"` }
if json.Unmarshal([]byte(order.Data), &odata) == nil && odata.LocalTime != "" {
if t, err := time.Parse(time.RFC3339, odata.LocalTime); err == nil {
item.Date = t.Format("20060102")
if t.Hour() != 0 || t.Minute() != 0 {
_, offset := t.Zone()
item.Time = fmt.Sprintf("%02d:%02d %s", t.Hour(), t.Minute(), offsetToTZName(offset))
var odata struct {
LocalTime string `json:"local_time"`
SummaryTranslated string `json:"summary_translated"`
}
if json.Unmarshal([]byte(order.Data), &odata) == nil {
if odata.LocalTime != "" {
if t, err := time.Parse(time.RFC3339, odata.LocalTime); err == nil {
item.Date = t.Format("20060102")
if t.Hour() != 0 || t.Minute() != 0 {
_, offset := t.Zone()
item.Time = fmt.Sprintf("%02d:%02d %s", t.Hour(), t.Minute(), offsetToTZName(offset))
}
}
}
if odata.SummaryTranslated != "" {
item.Meta = odata.SummaryTranslated
}
}
if item.Date == "" && order.Timestamp > 0 {
item.Date = time.Unix(order.Timestamp, 0).Format("20060102")
item.Date = time.Unix(order.Timestamp, 0).UTC().Format("20060102")
}
section.Items = append(section.Items, item)
}
@ -220,7 +265,67 @@ func BuildDossierSections(targetID, targetHex string, target *lib.Dossier, p *li
// Items loaded dynamically via JS
case "vitals":
section.Summary = T("vitals_desc")
// Load group containers (depth 2) — each is a metric type
groups, _ := lib.EntryRead(lib.SystemAccessorID, targetID, &lib.Filter{Category: lib.CategoryVital, Type: "root"})
if len(groups) > 0 {
metrics, _ := lib.EntryRead(lib.SystemAccessorID, targetID, &lib.Filter{Category: lib.CategoryVital, ParentID: groups[0].EntryID})
type chartPoint struct {
Date int64 `json:"date"` // unix seconds
Val float64 `json:"val"`
}
type chartMetric struct {
Name string `json:"name"`
Type string `json:"type"`
Unit string `json:"unit"`
Points []chartPoint `json:"points"`
Ref *chartRef `json:"ref,omitempty"`
}
var chartMetrics []chartMetric
for _, g := range metrics {
readings, _ := lib.EntryRead(lib.SystemAccessorID, targetID, &lib.Filter{
Category: lib.CategoryVital,
Type: "reading",
ParentID: g.EntryID,
})
latest := ""
latestDate := ""
var points []chartPoint
unit := ""
for _, r := range readings {
if r.Timestamp > 0 {
// Parse numeric value from summary like "94.5 kg"
parts := strings.SplitN(r.Summary, " ", 2)
if v, err := strconv.ParseFloat(parts[0], 64); err == nil {
points = append(points, chartPoint{Date: r.Timestamp, Val: v})
if unit == "" && len(parts) > 1 {
unit = parts[1]
}
}
}
latest = r.Summary
if r.Timestamp > 0 {
latestDate = time.Unix(r.Timestamp, 0).UTC().Format("2006-01-02")
}
}
section.Items = append(section.Items, SectionItem{
ID: g.EntryID,
Label: g.Summary,
Value: latest,
Date: latestDate,
})
if len(points) > 0 {
cm := chartMetric{Name: g.Summary, Type: g.Type, Unit: unit, Points: points}
cm.Ref = vitalRef(g.Type, target.Sex)
chartMetrics = append(chartMetrics, cm)
}
}
section.Summary = fmt.Sprintf("%d metrics", len(metrics))
if len(chartMetrics) > 0 {
if b, err := json.Marshal(chartMetrics); err == nil {
section.ChartData = string(b)
}
}
}
case "privacy":
// Handled separately - needs access list, not entries
@ -403,23 +508,29 @@ func buildLabItems(dossierID, lang string, T func(string) string) ([]SectionItem
// Use original local_time from Data JSON if available
var data struct {
LocalTime string `json:"local_time"`
LocalTime string `json:"local_time"`
SummaryTranslated string `json:"summary_translated"`
}
if json.Unmarshal([]byte(order.Data), &data) == nil && data.LocalTime != "" {
if t, err := time.Parse(time.RFC3339, data.LocalTime); err == nil {
item.Date = t.Format("20060102")
if t.Hour() != 0 || t.Minute() != 0 {
_, offset := t.Zone()
item.Time = fmt.Sprintf("%02d:%02d %s", t.Hour(), t.Minute(), offsetToTZName(offset))
if json.Unmarshal([]byte(order.Data), &data) == nil {
if data.LocalTime != "" {
if t, err := time.Parse(time.RFC3339, data.LocalTime); err == nil {
item.Date = t.Format("20060102")
if t.Hour() != 0 || t.Minute() != 0 {
_, offset := t.Zone()
item.Time = fmt.Sprintf("%02d:%02d %s", t.Hour(), t.Minute(), offsetToTZName(offset))
}
} else {
fmt.Printf("[DEBUG] Failed to parse local_time for %s: %s (err: %v)\n", order.EntryID, data.LocalTime, err)
}
} else {
fmt.Printf("[DEBUG] Failed to parse local_time for %s: %s (err: %v)\n", order.EntryID, data.LocalTime, err)
}
if data.SummaryTranslated != "" {
item.Meta = data.SummaryTranslated
}
}
// Fallback: if date still not set, use timestamp
if item.Date == "" && order.Timestamp > 0 {
t := time.Unix(order.Timestamp, 0)
t := time.Unix(order.Timestamp, 0).UTC()
item.Date = t.Format("20060102")
fmt.Printf("[DEBUG] Set date from timestamp for %s: %s -> %s\n", order.EntryID, order.Value, item.Date)
}
@ -431,15 +542,16 @@ func buildLabItems(dossierID, lang string, T func(string) string) ([]SectionItem
if len(children) > 0 {
item.Value = pluralT(len(children), "result", lang)
for _, c := range children {
// Extract LOINC for precise matching
var childData struct {
Loinc string `json:"loinc"`
Loinc string `json:"loinc"`
SummaryTranslated string `json:"summary_translated"`
}
json.Unmarshal([]byte(c.Data), &childData)
child := SectionItem{
Label: c.Summary,
Type: childData.Loinc, // Store LOINC in Type field
Type: childData.Loinc,
Meta: childData.SummaryTranslated,
}
item.Children = append(item.Children, child)
}
@ -463,7 +575,7 @@ func buildLabItems(dossierID, lang string, T func(string) string) ([]SectionItem
// Set date from timestamp
if standalone.Timestamp > 0 {
t := time.Unix(standalone.Timestamp, 0)
t := time.Unix(standalone.Timestamp, 0).UTC()
item.Date = t.Format("20060102")
}
@ -490,7 +602,7 @@ func docEntriesToSectionItems(entries []*lib.Entry) []SectionItem {
LinkTitle: "source",
}
if e.Timestamp > 0 {
item.Date = time.Unix(e.Timestamp, 0).Format("20060102")
item.Date = time.Unix(e.Timestamp, 0).UTC().Format("20060102")
}
items = append(items, item)
}
@ -525,7 +637,7 @@ func entriesToSectionItems(entries []*lib.Entry) []SectionItem {
Type: e.Type,
}
if e.Timestamp > 0 {
item.Date = time.Unix(e.Timestamp, 0).Format("20060102")
item.Date = time.Unix(e.Timestamp, 0).UTC().Format("20060102")
}
// Parse Data to build expandable children
@ -1139,7 +1251,7 @@ func handleLabSearch(w http.ResponseWriter, r *http.Request) {
}
}
if oj.Date == "" && order.Timestamp > 0 {
oj.Date = time.Unix(order.Timestamp, 0).Format("20060102")
oj.Date = time.Unix(order.Timestamp, 0).UTC().Format("20060102")
}
matchedOrders = append(matchedOrders, oj)
}

View File

@ -97,6 +97,36 @@ func parseGenomeVariant(line, format string) (string, string, bool) {
return rsid, genotype, true
}
// normalizeGenotype complements alleles to match the reference strand, then sorts.
func normalizeGenotype(genotype, alleles string) string {
if len(genotype) != 2 || alleles == "" {
if len(genotype) == 2 && genotype[0] > genotype[1] {
return string(genotype[1]) + string(genotype[0])
}
return genotype
}
valid := make(map[byte]bool)
for i := 0; i < len(alleles); i++ {
valid[alleles[i]] = true
}
comp := [256]byte{'A': 'T', 'T': 'A', 'C': 'G', 'G': 'C'}
var result [2]byte
for i := 0; i < 2; i++ {
b := genotype[i]
if valid[b] {
result[i] = b
} else if c := comp[b]; c != 0 {
result[i] = c
} else {
result[i] = b
}
}
if result[0] > result[1] {
result[0], result[1] = result[1], result[0]
}
return string(result[0]) + string(result[1])
}
// updateUploadStatus updates the status in the upload entry Data JSON
func updateUploadStatus(uploadID string, status string, details string) {
entry, err := lib.EntryGet(nil, uploadID) // nil ctx - internal operation
@ -168,8 +198,7 @@ func processGenomeUpload(uploadID string, dossierID string, filePath string) {
return variants[i].RSID < variants[j].RSID
})
// Load SNPedia data
snpediaPath := "/home/johan/dev/inou/snpedia-genotypes/genotypes.db"
// Load SNPedia data from reference DB (initialized at portal startup)
type CatInfo struct {
Category string
Subcategory string
@ -181,8 +210,7 @@ func processGenomeUpload(uploadID string, dossierID string, filePath string) {
// Key: rsid+genotype -> slice of category associations
snpediaMap := make(map[string][]CatInfo, 50000)
snpediaRsids := make(map[string]bool, 15000)
if snpDB, err := sql.Open("sqlite3", snpediaPath+"?mode=ro"); err == nil {
if snpDB := lib.RefDB(); snpDB != nil {
rows, _ := snpDB.Query("SELECT rsid, genotype_norm, gene, magnitude, repute, summary, category, subcategory FROM genotypes")
if rows != nil {
for rows.Next() {
@ -206,13 +234,30 @@ func processGenomeUpload(uploadID string, dossierID string, filePath string) {
}
rows.Close()
}
snpDB.Close()
}
// Match variants (only those with rsid in SNPedia)
// Build valid alleles per rsid from actual genotype entries (not the alleles column,
// which includes the reference allele that SNPedia doesn't use in genotype notation)
snpediaAlleles := make(map[string]string, len(snpediaRsids))
for key := range snpediaMap {
parts := strings.SplitN(key, ":", 2)
if len(parts) == 2 {
rsid, geno := parts[0], parts[1]
existing := snpediaAlleles[rsid]
for i := 0; i < len(geno); i++ {
if !strings.ContainsRune(existing, rune(geno[i])) {
existing += string(geno[i])
}
}
snpediaAlleles[rsid] = existing
}
}
// Match variants (only those with rsid in SNPedia), normalizing genotype to reference strand
matched := make([]Variant, 0, len(snpediaRsids))
for _, v := range variants {
if snpediaRsids[v.RSID] {
v.Genotype = normalizeGenotype(v.Genotype, snpediaAlleles[v.RSID])
matched = append(matched, v)
}
}
@ -238,11 +283,18 @@ func processGenomeUpload(uploadID string, dossierID string, filePath string) {
lib.EntryWrite("", parentEntry)
extractionID := parentEntry.EntryID
// Count shown/hidden per category, then create tiers
// Count shown/hidden per category (deduplicated by category+rsid)
type catCount struct{ Shown, Hidden int }
catCounts := map[string]*catCount{}
type catRsid struct{ cat, rsid string }
counted := map[catRsid]bool{}
for _, v := range matched {
for _, info := range snpediaMap[v.RSID+":"+v.Genotype] {
key := catRsid{info.Category, v.RSID}
if counted[key] {
continue
}
counted[key] = true
c, ok := catCounts[info.Category]
if !ok {
c = &catCount{}
@ -273,18 +325,30 @@ func processGenomeUpload(uploadID string, dossierID string, filePath string) {
}
// Batch insert variants (tier 3) - Type="rsid", Value=genotype
var batch []*lib.Entry
insertCount := 0
// Deduplicate: one entry per tier+rsid (merge subcategories, keep highest magnitude)
type variantKey struct{ tier, rsid string }
deduped := make(map[variantKey]*lib.Entry)
for _, v := range matched {
for _, info := range snpediaMap[v.RSID+":"+v.Genotype] {
tierID := tierMap[info.Category]
key := variantKey{tierID, v.RSID}
if existing, ok := deduped[key]; ok {
// Keep higher magnitude entry
if info.Magnitude > float64(100-existing.Ordinal)/10 {
data := fmt.Sprintf(`{"mag":%.1f,"rep":"%s","sum":"%s","sub":"%s"}`,
info.Magnitude, info.Repute, strings.ReplaceAll(info.Summary, `"`, `\"`), info.Subcategory)
existing.Ordinal = int(100 - info.Magnitude*10)
existing.Data = data
}
continue
}
// data includes subcategory (plain text - EntryWrite packs automatically)
data := fmt.Sprintf(`{"mag":%.1f,"rep":"%s","sum":"%s","sub":"%s"}`,
info.Magnitude, info.Repute, strings.ReplaceAll(info.Summary, `"`, `\"`), info.Subcategory)
batch = append(batch, &lib.Entry{
deduped[key] = &lib.Entry{
DossierID: dossierID,
ParentID: tierID,
Category: lib.CategoryGenome,
@ -295,16 +359,18 @@ func processGenomeUpload(uploadID string, dossierID string, filePath string) {
SearchKey: strings.ToLower(info.Gene),
SearchKey2: strings.ToLower(v.RSID),
Data: data,
})
insertCount++
if len(batch) >= 500 {
lib.EntryWrite("", batch...)
batch = batch[:0] // Reset slice
}
}
}
// Insert remaining entries
var batch []*lib.Entry
for _, e := range deduped {
batch = append(batch, e)
if len(batch) >= 500 {
lib.EntryWrite("", batch...)
batch = batch[:0]
}
}
if len(batch) > 0 {
lib.EntryWrite("", batch...)
}

View File

@ -314,12 +314,14 @@ func handleMCPToolsList(w http.ResponseWriter, req mcpRequest) {
tools := []map[string]interface{}{
{
"name": "list_dossiers",
"title": "List Dossiers",
"description": "List all patient dossiers accessible to this account.",
"inputSchema": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
"annotations": readOnly,
},
{
"name": "list_categories",
"title": "List Categories",
"description": "List data categories for a dossier with entry counts. Start here to see what's available before querying specific data.",
"inputSchema": map[string]interface{}{
"type": "object",
@ -332,25 +334,27 @@ func handleMCPToolsList(w http.ResponseWriter, req mcpRequest) {
},
{
"name": "list_entries",
"description": "List entries by category, type, or parent. All data is hierarchical — use parent to navigate deeper. For imaging: list studies (category='imaging'), then series (parent=study_id), then slices (parent=series_id). For labs: use search_key with LOINC code (e.g., '718-7'). For genome: search_key with gene name (e.g., 'MTHFR').",
"title": "Query Entries",
"description": "List entries by navigating the hierarchy. Always start with parent=<dossier_id> to get top-level entries, then use returned entry IDs to go deeper. For imaging: dossier → root → studies → series. To view slices, use fetch_contact_sheet on a series, then fetch_image with the slice ID. For labs: dossier → test groups → results. Use search_key for LOINC codes (labs) or gene names (genome).",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
"parent": map[string]interface{}{"type": "string", "description": "Parent entry ID — start with the dossier ID, then navigate deeper"},
"category": map[string]interface{}{"type": "string", "description": "Category name (use list_categories to discover)"},
"type": map[string]interface{}{"type": "string", "description": "Entry type within category"},
"search_key": map[string]interface{}{"type": "string", "description": "LOINC code for labs, gene name for genome"},
"parent": map[string]interface{}{"type": "string", "description": "Parent entry ID for hierarchical navigation"},
"from": map[string]interface{}{"type": "string", "description": "Timestamp start (Unix seconds)"},
"to": map[string]interface{}{"type": "string", "description": "Timestamp end (Unix seconds)"},
"limit": map[string]interface{}{"type": "number", "description": "Maximum results"},
},
"required": []string{"dossier"},
"required": []string{"dossier", "parent"},
},
"annotations": readOnly,
},
{
"name": "fetch_image",
"title": "Fetch Image",
"description": "Fetch slice image as base64 PNG. Optionally set window/level.",
"inputSchema": map[string]interface{}{
"type": "object",
@ -366,6 +370,7 @@ func handleMCPToolsList(w http.ResponseWriter, req mcpRequest) {
},
{
"name": "fetch_contact_sheet",
"title": "Fetch Contact Sheet",
"description": "Fetch contact sheet (thumbnail grid) for NAVIGATION ONLY. Use to identify slices, then fetch at full resolution. NEVER diagnose from thumbnails.",
"inputSchema": map[string]interface{}{
"type": "object",
@ -381,12 +386,14 @@ func handleMCPToolsList(w http.ResponseWriter, req mcpRequest) {
},
{
"name": "fetch_document",
"title": "Fetch Document",
"description": "Fetch full document content including extracted text, findings, and metadata. Use after finding documents via list_entries.",
"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": "Document entry ID (16-char hex)"},
"format": map[string]interface{}{"type": "string", "description": "Output format: 'original' (default, raw JSON), 'markdown' (formatted), 'translation' (English translation via AI)"},
},
"required": []string{"dossier", "entry_id"},
},
@ -394,6 +401,7 @@ func handleMCPToolsList(w http.ResponseWriter, req mcpRequest) {
},
{
"name": "get_version",
"title": "Server Version",
"description": "Get server version info.",
"inputSchema": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
"annotations": readOnly,
@ -448,6 +456,10 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss
typ, _ := params.Arguments["type"].(string)
searchKey, _ := params.Arguments["search_key"].(string)
parent, _ := params.Arguments["parent"].(string)
if parent == "" {
sendMCPResult(w, req.ID, mcpTextContent("ERROR: parent is required. Start with parent="+dossier+" (the dossier ID) to list top-level entries, then use returned entry IDs to navigate deeper."))
return
}
from, _ := params.Arguments["from"].(string)
to, _ := params.Arguments["to"].(string)
limit, _ := params.Arguments["limit"].(float64)
@ -493,16 +505,17 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss
case "fetch_document":
dossier, _ := params.Arguments["dossier"].(string)
entryID, _ := params.Arguments["entry_id"].(string)
format, _ := params.Arguments["format"].(string)
if dossier == "" || entryID == "" {
sendMCPError(w, req.ID, -32602, "dossier and entry_id required")
return
}
result, err := mcpFetchDocument(dossierID, dossier, entryID)
result, err := mcpFetchDocument(dossierID, dossier, entryID, format)
if err != nil {
sendMCPError(w, req.ID, -32000, err.Error())
return
}
sendMCPResult(w, req.ID, mcpTextContent(result))
sendMCPResult(w, req.ID, result)
case "get_version":
sendMCPResult(w, req.ID, mcpTextContent(fmt.Sprintf("Server: %s v%s", mcpServerName, mcpServerVersion)))

View File

@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"inou/lib"
)
@ -97,7 +98,7 @@ func mcpListDossiers(accessorID string) (string, error) {
}
func mcpQueryEntries(accessorID, dossier, category, typ, searchKey, parent, from, to string, limit int) (string, error) {
cat := 0
cat := -1 // any category
if category != "" {
cat = lib.CategoryFromString[category]
}
@ -144,10 +145,25 @@ func formatEntries(entries []*lib.Entry) string {
"parent_id": e.ParentID,
"category": lib.CategoryName(e.Category),
"type": e.Type,
"value": e.Value,
"summary": e.Summary,
"ordinal": e.Ordinal,
"timestamp": e.Timestamp,
}
if e.Data != "" {
var d map[string]any
if json.Unmarshal([]byte(e.Data), &d) == nil {
entry["data"] = d
}
}
switch e.Type {
case "root":
entry["hint"] = "Use list_entries with parent=" + e.EntryID + " to list studies"
case "study":
entry["hint"] = "Use list_entries with parent=" + e.EntryID + " to list series"
case "series":
entry["hint"] = "Use fetch_contact_sheet with series=" + e.EntryID + " to browse slices, then fetch_image with the slice ID"
}
result = append(result, entry)
}
pretty, _ := json.MarshalIndent(result, "", " ")
@ -193,34 +209,161 @@ func mcpFetchContactSheet(accessToken, dossier, series string, wc, ww float64) (
}
// --- Document fetch: returns extracted text + metadata from Data field ---
// mcpFetchDocument returns a full MCP content map.
// format: "original" = base64 PDF, "markdown" = formatted text, "translation" = translated text
func mcpFetchDocument(accessorID, dossier, entryID string) (string, error) {
entries, err := lib.EntryRead(accessorID, dossier, &lib.Filter{EntryID: entryID})
func mcpFetchDocument(accessorID, dossier, entryID, format string) (map[string]interface{}, error) {
// Use EntryGet (by ID only) — EntryRead with Category=0 default would exclude non-profile entries.
e, err := lib.EntryGet(&lib.AccessContext{AccessorID: accessorID}, entryID)
if err != nil {
return nil, err
}
if e == nil {
return nil, fmt.Errorf("document not found")
}
// Verify the entry belongs to the requested dossier.
if e.DossierID != dossier {
return nil, fmt.Errorf("document not found")
}
// Parse the Data field (populated by doc-processor).
var data map[string]interface{}
if e.Data != "" {
_ = json.Unmarshal([]byte(e.Data), &data)
}
if format == "" {
format = "original"
}
switch format {
case "markdown":
text := docToMarkdown(e, data)
return mcpTextContent(text), nil
case "translation":
text, err := docToTranslation(e, data)
if err != nil {
return nil, err
}
return mcpTextContent(text), nil
default: // "original" — return base64-encoded PDF
return docToOriginalPDF(e, data)
}
}
// docToOriginalPDF decrypts the source PDF and returns it as base64 MCP content.
func docToOriginalPDF(e *lib.Entry, data map[string]interface{}) (map[string]interface{}, error) {
sourceUpload, _ := data["source_upload"].(string)
if sourceUpload == "" {
return nil, fmt.Errorf("no PDF available for this document")
}
uploadEntry, err := lib.EntryGet(nil, sourceUpload)
if err != nil || uploadEntry == nil {
return nil, fmt.Errorf("upload entry not found")
}
var uploadData struct {
Path string `json:"path"`
}
if err := json.Unmarshal([]byte(uploadEntry.Data), &uploadData); err != nil || uploadData.Path == "" {
return nil, fmt.Errorf("no file path in upload entry")
}
pdfBytes, err := lib.DecryptFile(uploadData.Path)
if err != nil {
return nil, fmt.Errorf("decrypt failed: %w", err)
}
b64 := base64.StdEncoding.EncodeToString(pdfBytes)
summary := e.Summary
if summary == "" {
summary = "document"
}
return map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "resource",
"resource": map[string]interface{}{
"uri": "data:application/pdf;base64," + b64,
"mimeType": "application/pdf",
"text": summary,
},
},
},
}, nil
}
// docToMarkdown returns the pre-rendered markdown stored by doc-processor.
func docToMarkdown(e *lib.Entry, data map[string]interface{}) string {
if md, ok := data["markdown"].(string); ok && md != "" {
return md
}
// Fallback: summary only
return e.Summary
}
// docToTranslation returns the pre-translated markdown if available,
// otherwise translates the markdown field on-the-fly via Claude.
func docToTranslation(e *lib.Entry, data map[string]interface{}) (string, error) {
// Use pre-translated version if already stored by doc-processor.
if tr, ok := data["markdown_translated"].(string); ok && tr != "" {
return tr, nil
}
// Fall back to on-the-fly translation.
src, _ := data["markdown"].(string)
if src == "" {
src = e.Summary
}
if src == "" {
return "", fmt.Errorf("no text content to translate")
}
if lib.AnthropicKey == "" {
return "", fmt.Errorf("translation unavailable: no Anthropic API key configured")
}
prompt := "Translate the following medical document (markdown format) to English. Preserve all markdown formatting, medical terminology, values, and structure. Output only the translated markdown, no explanation.\n\n" + src
reqBody, _ := json.Marshal(map[string]interface{}{
"model": "claude-haiku-4-5",
"max_tokens": 4096,
"messages": []map[string]interface{}{
{"role": "user", "content": prompt},
},
})
req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", strings.NewReader(string(reqBody)))
if err != nil {
return "", err
}
if len(entries) == 0 {
return "", fmt.Errorf("document not found")
}
e := entries[0]
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", lib.AnthropicKey)
req.Header.Set("anthropic-version", "2023-06-01")
result := map[string]any{
"id": e.EntryID,
"type": e.Type,
"summary": e.Summary,
"timestamp": e.Timestamp,
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Merge Data fields (extracted text, findings, etc.) into result
if e.Data != "" {
var data map[string]interface{}
if json.Unmarshal([]byte(e.Data), &data) == nil {
for k, v := range data {
result[k] = v
}
}
var result struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
pretty, _ := json.MarshalIndent(result, "", " ")
return string(pretty), nil
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("translation API error: %s", result.Error.Message)
}
if len(result.Content) == 0 {
return "", fmt.Errorf("empty translation response")
}
return result.Content[0].Text, nil
}

View File

@ -47,21 +47,33 @@ func oauthJSON(w http.ResponseWriter, data any) {
json.NewEncoder(w).Encode(data)
}
// handleOAuthAuthorize handles GET /oauth/authorize
// Parameters: client_id, redirect_uri, response_type, state, code_challenge, code_challenge_method
// handleOAuthAuthorize handles GET/POST /oauth/authorize
// GET: validates params, shows consent screen
// POST: user approves/denies, generates code or returns error
func handleOAuthAuthorize(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
oauthError(w, "invalid_request", "Method must be GET", http.StatusMethodNotAllowed)
if r.Method != "GET" && r.Method != "POST" {
oauthError(w, "invalid_request", "Method must be GET or POST", http.StatusMethodNotAllowed)
return
}
// Parse parameters
clientID := r.URL.Query().Get("client_id")
redirectURI := r.URL.Query().Get("redirect_uri")
responseType := r.URL.Query().Get("response_type")
state := r.URL.Query().Get("state")
codeChallenge := r.URL.Query().Get("code_challenge")
codeChallengeMethod := r.URL.Query().Get("code_challenge_method")
// Parse parameters (from query on GET, form on POST)
var clientID, redirectURI, responseType, state, codeChallenge, codeChallengeMethod string
if r.Method == "GET" {
clientID = r.URL.Query().Get("client_id")
redirectURI = r.URL.Query().Get("redirect_uri")
responseType = r.URL.Query().Get("response_type")
state = r.URL.Query().Get("state")
codeChallenge = r.URL.Query().Get("code_challenge")
codeChallengeMethod = r.URL.Query().Get("code_challenge_method")
} else {
r.ParseForm()
clientID = r.FormValue("client_id")
redirectURI = r.FormValue("redirect_uri")
responseType = r.FormValue("response_type")
state = r.FormValue("state")
codeChallenge = r.FormValue("code_challenge")
codeChallengeMethod = r.FormValue("code_challenge_method")
}
// Validate required parameters
if clientID == "" {
@ -114,7 +126,39 @@ func handleOAuthAuthorize(w http.ResponseWriter, r *http.Request) {
return
}
// User is logged in - generate authorization code
// GET: show consent screen
if r.Method == "GET" {
render(w, r, PageData{
Page: "consent",
Lang: getLang(r),
Dossier: dossier,
ClientName: client.Name,
ClientID: clientID,
RedirectURI: redirectURI,
ResponseType: responseType,
State: state,
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
UserName: dossier.Name,
})
return
}
// POST: handle consent decision
if r.FormValue("action") == "deny" {
redirectURL, _ := url.Parse(redirectURI)
q := redirectURL.Query()
q.Set("error", "access_denied")
q.Set("error_description", "User denied access")
if state != "" {
q.Set("state", state)
}
redirectURL.RawQuery = q.Encode()
http.Redirect(w, r, redirectURL.String(), http.StatusSeeOther)
return
}
// User approved - generate authorization code
code, err := lib.OAuthCodeCreate(
clientID,
dossier.DossierID,

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" fill="#FFFFFF"/>
<g fill="#b45309" transform="translate(26.175, 12.775)">
<path d="M3.65 48.50L3.65 21.10L11.65 21.10L11.65 48.50L3.65 48.50M0 27L0 21.10L11.65 21.10L11.65 27L0 27M6.75 18.40Q4.50 18.40 3.43 17.22Q2.35 16.05 2.35 14.25Q2.35 12.40 3.43 11.23Q4.50 10.05 6.75 10.05Q9 10.05 10.07 11.23Q11.15 12.40 11.15 14.25Q11.15 16.05 10.07 17.22Q9 18.40 6.75 18.40Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 505 B

View File

@ -1567,9 +1567,12 @@ a:hover {
.sg-profile-card.border-moderate { border-left-color: var(--accent); }
.sg-profile-card.border-rich { border-left-color: var(--success); }
.sg-profile-card h3 { font-size: 1.25rem; margin-bottom: 4px; }
.card-actions { position: absolute; top: 14px; right: 14px; display: flex; gap: 4px; }
.card-actions a { color: var(--text-muted); text-decoration: none; padding: 2px 5px; font-size: 1.1rem; line-height: 1; border-radius: 4px; }
.card-actions a:hover { color: var(--accent); background: var(--accent-light); }
.card-name-row { display: flex; align-items: baseline; gap: 8px; }
.card-name-row h3 { flex: 1; min-width: 0; }
.card-actions { display: flex; gap: 4px; flex-shrink: 0; }
.card-actions a, .card-actions button { color: var(--text-muted); text-decoration: none; padding: 2px 5px; font-size: 1.1rem; line-height: 1; border-radius: 4px; position: relative; }
.card-actions a:hover, .card-actions button:hover { color: var(--accent); background: var(--accent-light); }
[data-tooltip]:hover::after { content: attr(data-tooltip); position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); padding: 4px 8px; background: var(--text); color: var(--bg); font-size: 0.7rem; white-space: nowrap; border-radius: 4px; pointer-events: none; }
.sg-profile-card .card-meta { margin-bottom: 0; }
.card-context { font-size: 0.8rem; color: var(--text-subtle); font-style: italic; margin: 0; }
.card-flag { font-size: 0.85rem; vertical-align: middle; }

View File

@ -0,0 +1,33 @@
{{define "consent"}}
<div class="sg-container" style="justify-content: center;">
<div style="flex: 1; display: flex; align-items: center; justify-content: center;">
<div class="data-card" style="padding: 48px; max-width: 440px; width: 100%;">
<div style="text-align: center; margin-bottom: 24px; font-size: 1.5rem;"><span style="font-weight: 700; color: var(--accent);">inou</span> <span style="font-weight: 400; color: var(--text-muted);">health</span></div>
<h1 style="font-size: 1.75rem; font-weight: 700; text-align: center; margin-bottom: 8px;">Authorize Access</h1>
<p style="text-align: center; color: var(--text-muted); font-weight: 300; margin-bottom: 32px;">
<strong>{{.ClientName}}</strong> wants to access your health data as <strong>{{.UserName}}</strong>.
</p>
<div style="background: var(--bg-surface); border-radius: 8px; padding: 16px; margin-bottom: 24px;">
<p style="font-size: 0.9rem; color: var(--text); margin: 0;">This application will be able to read all health data in your dossier.</p>
</div>
<form action="/oauth/authorize" method="POST">
<input type="hidden" name="client_id" value="{{.ClientID}}">
<input type="hidden" name="redirect_uri" value="{{.RedirectURI}}">
<input type="hidden" name="response_type" value="{{.ResponseType}}">
<input type="hidden" name="state" value="{{.State}}">
<input type="hidden" name="code_challenge" value="{{.CodeChallenge}}">
<input type="hidden" name="code_challenge_method" value="{{.CodeChallengeMethod}}">
<button type="submit" name="action" value="allow" class="btn btn-primary btn-full" style="margin-bottom: 12px;">Allow</button>
<button type="submit" name="action" value="deny" class="btn btn-full" style="background: transparent; color: var(--text-muted); border: 1px solid var(--border);">Deny</button>
</form>
</div>
</div>
{{template "footer"}}
</div>
{{end}}

View File

@ -1,51 +1,25 @@
{{define "dashboard"}}
<div class="sg-container">
<h1 style="font-size: 2.5rem; font-weight: 700;">{{.T.dossiers}}</h1>
<p class="intro" style="font-size: 1.15rem; font-weight: 300; line-height: 1.8;">{{.T.dossiers_intro}}</p>
<div class="profiles-grid" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));">
<!-- Self dossier -->
<div class="card sg-profile-card {{borderLevel .SelfStats.TotalCount}}" style="position: relative;">
<div class="card-actions">
<a href="/dossier/{{.Dossier.DossierID}}/upload" title="{{.T.upload_files}}">&#8682;</a>
<a href="/dossier/{{.Dossier.DossierID}}/edit" title="{{.T.edit}}">&#9998;</a>
</div>
<a href="/dossier/{{.Dossier.DossierID}}" style="text-decoration: none; color: inherit; display: contents;">
<div class="profile-header">
<div class="avatar" style="background: {{initialColor .Dossier.DossierID}};">{{initials .Dossier.Name}}</div>
<div>
<h3>{{.Dossier.Name}}{{with langFlag .Dossier.Preferences.Language}} <span class="card-flag">{{.}}</span>{{end}}</h3>
<p class="card-meta">{{.T.you}}</p>
</div>
</div>
<p class="sg-profile-dob">{{printf "%.10s" .Dossier.DateOfBirth}}{{with age .Dossier.DateOfBirth}} · {{.}}{{end}}{{if .Dossier.Sex}} · {{sexT .Dossier.Sex .Lang}}{{end}}</p>
<div class="sg-profile-stats">
{{if .SelfStats.Chips}}
{{range .SelfStats.Chips}}<span class="sg-profile-chip {{.Color}}">{{.Icon}} {{if .Count}}{{.Count}} {{end}}{{.Label}}</span>{{end}}
{{if .SelfStats.OverflowCount}}<span class="sg-profile-chip chip-muted">+{{.SelfStats.OverflowCount}} more</span>{{end}}
{{else}}
<span class="sg-profile-chip chip-muted">No data yet</span>
{{end}}
</div>
</a>
</div>
<!-- Accessible dossiers -->
{{range .AccessibleDossiers}}
{{if .NewGroup}}<hr class="grid-separator">{{end}}
<div class="card sg-profile-card {{borderLevel .Stats.TotalCount}}" style="position: relative;">
{{if .CanEdit}}<div class="card-actions">
<a href="/dossier/{{.DossierID}}/upload" title="{{$.T.upload_files}}">&#8682;</a>
<a href="/dossier/{{.DossierID}}/edit" title="{{$.T.edit}}">&#9998;</a>
</div>{{end}}
{{if eq .RelationInt 99}}<form method="POST" action="/dossier/{{.DossierID}}/revoke" style="position: absolute; top: 16px; right: 16px; margin: 0;" onsubmit="return confirm('Remove demo dossier from your list?')"><input type="hidden" name="accessor_id" value="{{$.Dossier.DossierID}}"><button type="submit" class="edit-link" title="{{$.T.remove}}" style="background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 4px;">&#10005;</button></form>{{end}}
<div class="card sg-profile-card {{borderLevel .Stats.TotalCount}}">
<a href="/dossier/{{.DossierID}}" style="text-decoration: none; color: inherit; display: contents;">
<div class="profile-header">
<div class="avatar" style="background: {{initialColor .DossierID}};">{{initials .Name}}</div>
<div>
<h3>{{.Name}}{{with langFlag .Lang}} <span class="card-flag">{{.}}</span>{{end}}</h3>
<p class="card-meta">{{if eq .RelationInt 99}}{{$.T.role}}: {{.Relation}}{{else}}{{$.T.my_role}}: {{.Relation}}{{if .IsCareReceiver}} · <span class="badge badge-care">{{$.T.care}}</span>{{end}}{{end}}</p>
{{if .Context}}<p class="card-context">{{.Context}}</p>{{end}}
<div style="flex: 1; min-width: 0;">
<div class="card-name-row">
<h3>{{.Name}}{{with langFlag .Lang}} <span class="card-flag">{{.}}</span>{{end}}</h3>
{{if .CanEdit}}<span class="card-actions" onclick="event.preventDefault(); event.stopPropagation();">
<a href="/dossier/{{.DossierID}}/upload" data-tooltip="{{$.T.upload_files}}">&#8682;</a>
<a href="/dossier/{{.DossierID}}/edit" data-tooltip="{{$.T.edit}}">&#9998;</a>
</span>{{end}}
{{if eq .RelationInt 99}}<form method="POST" action="/dossier/{{.DossierID}}/revoke" class="card-actions" style="margin: 0;" onclick="event.stopPropagation();" onsubmit="event.stopPropagation(); return confirm('Remove demo dossier from your list?')"><input type="hidden" name="accessor_id" value="{{$.Dossier.DossierID}}"><button type="submit" class="edit-link" data-tooltip="{{$.T.remove}}" style="background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 2px 5px; font-size: 1.1rem;">&#10005;</button></form>{{end}}
</div>
<p class="card-meta">{{if .IsSelf}}{{$.T.you}}{{else if eq .RelationInt 99}}{{$.T.role}}: {{.Relation}}{{else}}{{$.T.my_role}}: {{.Relation}}{{if .IsCareReceiver}} · <span class="badge badge-care">{{$.T.care}}</span>{{end}}{{end}}</p>
<p class="card-context">{{if .Context}}{{.Context}}{{else}}&nbsp;{{end}}</p>
</div>
</div>
<p class="sg-profile-dob">{{printf "%.10s" .DateOfBirth}}{{with age .DateOfBirth}} · {{.}}{{end}}{{if .Sex}} · {{sexT .Sex $.Lang}}{{end}}</p>

234
portal/templates/docs.tmpl Normal file
View File

@ -0,0 +1,234 @@
{{define "docs"}}
<style>
.docs-container {
max-width: 1200px;
margin: 0 auto;
padding: 48px 24px 80px;
}
.docs-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 48px;
margin-bottom: 24px;
}
.docs-card h1 {
font-size: 2.5rem;
font-weight: 700;
color: var(--text);
margin-bottom: 16px;
}
.docs-card .intro {
font-size: 1.15rem;
font-weight: 300;
color: var(--text-muted);
line-height: 1.8;
margin-bottom: 0;
}
.docs-card h2 {
font-size: 1.4rem;
font-weight: 600;
color: var(--text);
margin-top: 0;
margin-bottom: 24px;
}
.docs-card h3 {
font-size: 1.1rem;
font-weight: 600;
color: var(--text);
margin-top: 24px;
margin-bottom: 8px;
}
.docs-card h3:first-child { margin-top: 0; }
.docs-card p, .docs-card li {
font-size: 1rem;
font-weight: 300;
color: var(--text-muted);
line-height: 1.8;
}
.docs-card p { margin-bottom: 16px; }
.docs-card p:last-child { margin-bottom: 0; }
.docs-card ul { margin-bottom: 16px; padding-left: 24px; }
.docs-card li { margin-bottom: 4px; }
.docs-card strong {
font-weight: 600;
color: var(--text);
}
.docs-card a { color: var(--accent); }
.docs-card code {
background: var(--bg-surface);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9rem;
}
.docs-card pre {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
overflow-x: auto;
margin-bottom: 16px;
}
.docs-card pre code {
background: none;
padding: 0;
}
.example-box {
background: var(--bg-surface);
border-left: 3px solid var(--accent);
border-radius: 0 8px 8px 0;
padding: 16px 20px;
margin-bottom: 16px;
}
.example-box .prompt {
font-weight: 500;
color: var(--text);
margin-bottom: 8px;
}
.example-box .explanation {
font-size: 0.9rem;
color: var(--text-muted);
margin: 0;
}
.tool-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
.tool-table th, .tool-table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
font-size: 0.95rem;
}
.tool-table th {
font-weight: 600;
color: var(--text);
}
.tool-table td {
color: var(--text-muted);
font-weight: 300;
}
</style>
<div class="docs-container">
<div class="docs-card">
<h1>inou for Claude</h1>
<p class="intro">
<span style="font-weight: 700; color: var(--accent);">inou</span> gives Claude direct access to your health data
for independent medical analysis. Imaging, labs, genomics, and 27 data categories &mdash;
all queryable through a single MCP integration.
</p>
</div>
<div class="docs-card">
<h2>What it does</h2>
<p>
inou connects Claude to your personal health records stored on the inou platform.
Claude can browse your medical imaging (MRI, CT, X-ray), review lab results with trends over time,
analyze genomic variants, and read clinical documents &mdash; forming its own independent medical opinions
from the raw data rather than echoing prior assessments.
</p>
<p>Key capabilities:</p>
<ul>
<li><strong>Medical imaging</strong> &mdash; View DICOM studies (MRI, CT, X-ray) with adjustable window/level, navigate series via contact sheets</li>
<li><strong>Lab results</strong> &mdash; Query by LOINC code, track trends across multiple draws, SI unit normalization</li>
<li><strong>Genomic data</strong> &mdash; Search variants by gene name, review pharmacogenomic and disease-risk markers</li>
<li><strong>Clinical documents</strong> &mdash; Access uploaded documents with extracted text and metadata</li>
<li><strong>27 data categories</strong> &mdash; Medications, diagnoses, surgeries, vitals, family history, and more</li>
</ul>
</div>
<div class="docs-card">
<h2>Setup</h2>
<h3>1. Sign in</h3>
<p>
When you connect inou to Claude, you'll be redirected to <strong>inou.com</strong> to sign in.
Enter your email and verify with the code sent to your inbox. No password needed.
</p>
<h3>2. Authorize</h3>
<p>
Review the access request and click <strong>Allow</strong> to grant Claude read-only access to your health data.
</p>
<h3>3. Start asking</h3>
<p>
Claude will automatically discover your dossiers and available data. Ask about your labs, imaging, genome, or any health topic.
</p>
<p>
<strong>New users:</strong> A demo dossier (Jane Doe) with sample labs, imaging, and genome data
is automatically available so you can explore the integration immediately.
</p>
</div>
<div class="docs-card">
<h2>Available tools</h2>
<table class="tool-table">
<tr><th>Tool</th><th>Description</th></tr>
<tr><td><code>list_dossiers</code></td><td>List all patient dossiers accessible to your account</td></tr>
<tr><td><code>list_categories</code></td><td>See what data categories exist for a dossier with entry counts</td></tr>
<tr><td><code>list_entries</code></td><td>Query entries by category, type, LOINC code, gene, date range, or parent hierarchy</td></tr>
<tr><td><code>fetch_image</code></td><td>Fetch a DICOM slice as PNG with adjustable window/level</td></tr>
<tr><td><code>fetch_contact_sheet</code></td><td>Thumbnail grid for navigating imaging series</td></tr>
<tr><td><code>fetch_document</code></td><td>Retrieve document content with extracted text and metadata</td></tr>
<tr><td><code>get_version</code></td><td>Server version information</td></tr>
</table>
<p>All tools are <strong>read-only</strong>. Claude cannot modify your health data.</p>
</div>
<div class="docs-card">
<h2>Examples</h2>
<div class="example-box">
<p class="prompt">"Review Jane Doe's CBC trend over the past year. Are there any concerning patterns?"</p>
<p class="explanation">Claude queries lab entries by LOINC codes for WBC, RBC, hemoglobin, platelets, and differential. It compares values across four blood draws and identifies the December anomaly: elevated WBC (13.2), low hemoglobin (10.8), microcytic indices (MCV 72.4), and reactive thrombocytosis (452K) &mdash; suggesting iron deficiency with possible infection.</p>
</div>
<div class="example-box">
<p class="prompt">"Look at Jane's brain MRI. Walk me through what you see."</p>
<p class="explanation">Claude lists imaging studies, navigates to the brain MRI series, fetches a contact sheet for orientation, then retrieves individual slices at diagnostic resolution. It describes anatomy, signal characteristics, and any visible findings &mdash; forming its own read independent of any radiologist report.</p>
</div>
<div class="example-box">
<p class="prompt">"What genetic variants does Jane carry that could affect medication metabolism?"</p>
<p class="explanation">Claude queries genome entries filtered by pharmacogenomic genes (CYP2D6, CYP2C19, CYP3A4, etc.), reviews variant classifications and zygosity, and maps findings to drug metabolism implications &mdash; identifying poor/rapid metabolizer status for specific medication classes.</p>
</div>
</div>
<div class="docs-card">
<h2>Security &amp; privacy</h2>
<ul>
<li><strong>Encryption</strong> &mdash; All data encrypted at rest (AES-256-GCM, FIPS 140-3)</li>
<li><strong>OAuth 2.1</strong> &mdash; Authorization code flow with PKCE, no passwords stored</li>
<li><strong>Read-only</strong> &mdash; Claude can only read data, never modify or delete</li>
<li><strong>RBAC</strong> &mdash; Role-based access control enforced at every data access point</li>
<li><strong>Short-lived tokens</strong> &mdash; Access tokens expire in 15 minutes, refresh tokens rotate on use</li>
</ul>
<p>
Read our full <a href="/privacy-policy">Privacy Policy</a>.
For questions, contact <a href="mailto:support@inou.com">support@inou.com</a>.
</p>
</div>
</div>
{{end}}

View File

@ -10,7 +10,13 @@
</p>
{{end}}
</div>
<a href="/dashboard" class="btn btn-secondary btn-small">← {{.T.back_to_dossiers}}</a>
<div style="display: flex; align-items: center; gap: 12px;">
<div class="card-actions" style="position: static;">
<a href="/dossier/{{.TargetDossier.DossierID}}/upload" title="{{.T.upload_files}}">&#8682;</a>
<a href="/dossier/{{.TargetDossier.DossierID}}/edit" title="{{.T.edit}}">&#9998;</a>
</div>
<a href="/dashboard" class="btn btn-secondary btn-small">← {{.T.back_to_dossiers}}</a>
</div>
</div>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
@ -597,10 +603,21 @@ function buildSVGChart(name, unit, points, abbr, globalTMin, globalTMax) {
const vals = points.map(p => p.val);
let yMin = Math.min(...vals), yMax = Math.max(...vals);
// Include reference range in Y axis bounds if available
// Include reference bounds in Y axis — but for one-sided refs, only pull toward
// the boundary if data is within 2x the padding distance of it
if (ref) {
yMin = Math.min(yMin, ref.refLow);
yMax = Math.max(yMax, ref.refHigh);
const dir = ref.direction || '';
if (dir === 'higher_better') {
// Only show lower bound if data is near it (within 50% of data range)
const range = yMax - yMin || 1;
if (ref.refLow > yMin - range * 0.5) yMin = Math.min(yMin, ref.refLow);
} else if (dir === 'lower_better') {
const range = yMax - yMin || 1;
if (ref.refHigh < yMax + range * 0.5) yMax = Math.max(yMax, ref.refHigh);
} else {
if (ref.refLow > 0) yMin = Math.min(yMin, ref.refLow);
if (ref.refHigh > 0) yMax = Math.max(yMax, ref.refHigh);
}
}
const yPad = (yMax - yMin) * 0.15 || 1;
yMin -= yPad; yMax += yPad;
@ -621,22 +638,39 @@ function buildSVGChart(name, unit, points, abbr, globalTMin, globalTMax) {
// Reference band (drawn first, behind everything)
let refBand = '';
if (ref) {
const bandTop = yScale(ref.refHigh);
const bandBot = yScale(ref.refLow);
const dir = ref.direction || '';
const chartTop = PAD.top;
const chartBot = PAD.top + ph;
// Red zones above and below normal range
if (bandTop > chartTop) {
refBand += `<rect x="${PAD.left}" y="${chartTop}" width="${pw}" height="${bandTop - chartTop}" fill="#fee2e2" opacity="0.5"/>`;
if (dir === 'higher_better') {
// Only lower bound: red below, green above
const bandBot = yScale(ref.refLow);
refBand += `<rect x="${PAD.left}" y="${chartTop}" width="${pw}" height="${chartBot - chartTop}" fill="#dcfce7" opacity="0.6"/>`;
if (bandBot < chartBot) {
refBand += `<rect x="${PAD.left}" y="${bandBot}" width="${pw}" height="${chartBot - bandBot}" fill="#fee2e2" opacity="0.5"/>`;
}
refBand += `<line x1="${PAD.left}" y1="${bandBot}" x2="${W - PAD.right}" y2="${bandBot}" stroke="#86efac" stroke-width="1" stroke-dasharray="4,3"/>`;
} else if (dir === 'lower_better') {
// Only upper bound: red above, green below
const bandTop = yScale(ref.refHigh);
refBand += `<rect x="${PAD.left}" y="${chartTop}" width="${pw}" height="${chartBot - chartTop}" fill="#dcfce7" opacity="0.6"/>`;
if (bandTop > chartTop) {
refBand += `<rect x="${PAD.left}" y="${chartTop}" width="${pw}" height="${bandTop - chartTop}" fill="#fee2e2" opacity="0.5"/>`;
}
refBand += `<line x1="${PAD.left}" y1="${bandTop}" x2="${W - PAD.right}" y2="${bandTop}" stroke="#86efac" stroke-width="1" stroke-dasharray="4,3"/>`;
} else {
// Two-sided: red above and below, green in range
const bandTop = yScale(ref.refHigh);
const bandBot = yScale(ref.refLow);
if (bandTop > chartTop) {
refBand += `<rect x="${PAD.left}" y="${chartTop}" width="${pw}" height="${bandTop - chartTop}" fill="#fee2e2" opacity="0.5"/>`;
}
if (bandBot < chartBot) {
refBand += `<rect x="${PAD.left}" y="${bandBot}" width="${pw}" height="${chartBot - bandBot}" fill="#fee2e2" opacity="0.5"/>`;
}
refBand += `<rect x="${PAD.left}" y="${bandTop}" width="${pw}" height="${bandBot - bandTop}" fill="#dcfce7" opacity="0.6"/>`;
refBand += `<line x1="${PAD.left}" y1="${bandTop}" x2="${W - PAD.right}" y2="${bandTop}" stroke="#86efac" stroke-width="1" stroke-dasharray="4,3"/>`;
refBand += `<line x1="${PAD.left}" y1="${bandBot}" x2="${W - PAD.right}" y2="${bandBot}" stroke="#86efac" stroke-width="1" stroke-dasharray="4,3"/>`;
}
if (bandBot < chartBot) {
refBand += `<rect x="${PAD.left}" y="${bandBot}" width="${pw}" height="${chartBot - bandBot}" fill="#fee2e2" opacity="0.5"/>`;
}
// Green normal range
refBand += `<rect x="${PAD.left}" y="${bandTop}" width="${pw}" height="${bandBot - bandTop}" fill="#dcfce7" opacity="0.6"/>`;
// Boundary lines
refBand += `<line x1="${PAD.left}" y1="${bandTop}" x2="${W - PAD.right}" y2="${bandTop}" stroke="#86efac" stroke-width="1" stroke-dasharray="4,3"/>`;
refBand += `<line x1="${PAD.left}" y1="${bandBot}" x2="${W - PAD.right}" y2="${bandBot}" stroke="#86efac" stroke-width="1" stroke-dasharray="4,3"/>`;
}
// Y-axis: 4 ticks
@ -675,7 +709,10 @@ function buildSVGChart(name, unit, points, abbr, globalTMin, globalTMax) {
let dotColor = '#B45309'; // amber default
let textColor = '#1f2937';
if (ref) {
const inRange = p.val >= ref.refLow && p.val <= ref.refHigh;
const dir = ref.direction || '';
const inRange = dir === 'higher_better' ? p.val >= ref.refLow :
dir === 'lower_better' ? p.val <= ref.refHigh :
p.val >= ref.refLow && p.val <= ref.refHigh;
if (inRange) {
dotColor = '#16a34a'; // green
} else {
@ -711,6 +748,38 @@ function buildSVGChart(name, unit, points, abbr, globalTMin, globalTMax) {
</svg>`;
}
// Render vitals charts on page load
document.querySelectorAll('[data-chart]').forEach(wrapper => {
const metrics = JSON.parse(wrapper.dataset.chart);
const body = wrapper.querySelector('.filter-chart-body');
if (!metrics || metrics.length === 0) { wrapper.style.display = 'none'; return; }
// Calculate global time range
let globalTMin = Infinity, globalTMax = -Infinity;
for (const m of metrics) {
for (const p of m.points) {
const t = p.date * 1000;
if (t < globalTMin) globalTMin = t;
if (t > globalTMax) globalTMax = t;
}
}
globalTMax = Math.max(globalTMax, Date.now());
// Populate labRefData so buildSVGChart picks up refs
for (const m of metrics) {
if (m.ref) labRefData[m.type] = m.ref;
}
let html = '';
for (const m of metrics) {
const points = m.points.map(p => ({ date: new Date(p.date * 1000), val: p.val }));
points.sort((a, b) => a.date - b.date);
if (points.length === 0) continue;
html += buildSVGChart(m.name, m.unit, points, m.type, globalTMin, globalTMax);
}
body.innerHTML = html;
});
// Genetics dynamic loading (if genetics section exists)
{{if .HasGenome}}
const i18n = {
@ -998,6 +1067,13 @@ loadGeneticsCategories();
</div>
{{end}}
{{if .ChartData}}
<div class="filter-chart collapsed" id="{{.ID}}-charts" data-chart='{{.ChartData}}'>
<div class="filter-chart-header" onclick="this.parentNode.classList.toggle('collapsed')"><span class="filter-chart-toggle">&#9660;</span> Trends</div>
<div class="filter-chart-body"></div>
</div>
{{end}}
{{if .Dynamic}}
<div class="data-table" id="{{.ID}}-content"></div>
{{else if .Items}}

View File

@ -13,6 +13,46 @@
margin-bottom: 24px;
}
/* Carousel */
.carousel {
position: relative;
width: 100%;
aspect-ratio: 16/9;
overflow: hidden;
border-radius: 6px;
margin-bottom: 32px;
}
.carousel-track {
display: flex;
height: 100%;
transition: transform 0.5s ease;
}
.carousel-slide {
min-width: 100%;
height: 100%;
background-size: cover;
background-position: center;
}
.carousel-dots {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 32px;
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
border: none;
padding: 0;
cursor: pointer;
transition: background 0.2s;
}
.carousel-dot.active {
background: var(--accent);
}
/* Hero - Block 1 */
.hero-sources {
@ -45,12 +85,12 @@
.hero-answer {
text-align: center;
font-size: 1.25rem;
font-weight: 400;
font-size: 1.7rem;
font-weight: 500;
color: var(--text);
line-height: 1.8;
line-height: 1.5;
margin-top: 16px;
margin-bottom: 32px;
margin-bottom: 8px;
}
.hero-answer .inou {
font-weight: 700;
@ -59,10 +99,20 @@
.hero-tagline {
text-align: center;
font-size: 1.3rem;
font-weight: 600;
font-size: 2.8rem;
font-weight: 700;
color: var(--text);
margin-bottom: 32px;
margin-bottom: 12px;
}
.carousel-caption {
text-align: center;
font-size: 0.95rem;
color: var(--muted);
line-height: 1.5;
min-height: 3em;
padding: 0 24px;
margin-bottom: 24px;
}
.hero-cta { margin-bottom: 0; text-align: center; }
@ -260,8 +310,10 @@
}
.hero-pivot .emphasis { font-size: 1.3rem; }
.hero-answer {
text-align: center; font-size: 1.05rem; margin-top: 16px;
margin-bottom: 32px; }
text-align: center; font-size: 1.2rem; margin-top: 16px;
margin-bottom: 8px; }
.hero-tagline { font-size: 2rem; margin-bottom: 8px; }
.carousel-caption { font-size: 0.85rem; }
.hero-cta .btn { padding: 14px 40px; }
.story-pair .data { font-size: 1rem; }
.story-pair .reality { font-size: 0.95rem; }
@ -289,8 +341,27 @@
<div class="landing-card">
<div class="hero">
<div class="hero-answer"><span class="inou">inou</span> organizes and shares your health dossier with your AI — securely and privately.</div>
<div class="hero-tagline">Your health, understood.</div>
<div class="hero-answer">All your health data — organized, private, and ready for your AI.</div>
<div class="carousel">
<div class="carousel-track">
<div class="carousel-slide" style="background-image: url('/static/carousel-1.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-2.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-3.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-4.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-5.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-6.webp')"></div>
</div>
</div>
<div class="carousel-caption" id="carousel-caption">Track your lab trends over time — see exactly what your AI sees when it flags a change.</div>
<div class="carousel-dots">
<button class="carousel-dot active" data-index="0"></button>
<button class="carousel-dot" data-index="1"></button>
<button class="carousel-dot" data-index="2"></button>
<button class="carousel-dot" data-index="3"></button>
<button class="carousel-dot" data-index="4"></button>
<button class="carousel-dot" data-index="5"></button>
</div>
<div class="hero-cta">
{{if .Dossier}}<a href="/invite" class="btn btn-primary">Invite a friend</a>{{else}}<a href="/start" class="btn btn-primary">Sign in</a>{{end}}
{{if .Error}}<div class="error" style="margin-top: 24px;">{{.Error}}</div>{{end}}
@ -418,6 +489,31 @@
{{template "footer"}}
</div>
<script>
(function() {
var track = document.querySelector('.carousel-track');
var dots = document.querySelectorAll('.carousel-dot');
var caption = document.getElementById('carousel-caption');
var captions = [
'Track your lab trends over time \u2014 see exactly what your AI sees when it flags a change.',
'Your labs, scans, and genome in one place \u2014 browse everything your AI has access to.',
'View your own MRI \u2014 zoom into the same slices your AI analyzed.',
'Your brain scan in 3D \u2014 navigate every plane, verify every finding your AI made.',
'Your AI connects the dots across labs and genome \u2014 and explains it in plain language.',
'Your X-ray, full resolution \u2014 zoom in on the findings your AI flagged.'
];
var count = dots.length;
var current = 0;
function go(i) {
current = i;
track.style.transform = 'translateX(-' + (i * 100) + '%)';
dots.forEach(function(d, j) { d.classList.toggle('active', j === i); });
caption.textContent = captions[i];
}
dots.forEach(function(d) {
d.addEventListener('click', function() { go(+d.dataset.index); });
});
setInterval(function() { go((current + 1) % count); }, 8000);
})();
</script>
{{end}}
<!-- -->
<!-- test -->

View File

@ -1,121 +1,519 @@
{{define "landing_fr"}}
<style>
.landing-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 48px; width: 100%; margin-left: auto; margin-right: auto; margin-bottom: 24px; }
.hero-answer { text-align: center; font-size: 1.25rem; font-weight: 400; color: var(--text); line-height: 1.8; margin-top: 16px; margin-bottom: 32px; }
.hero-answer .inou { font-weight: 700; color: var(--accent); }
.hero-tagline { text-align: center; font-size: 1.3rem; font-weight: 600; color: var(--text); margin-bottom: 32px; }
.landing-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 48px;
width: 100%;
margin-left: auto;
margin-right: auto;
margin-bottom: 24px;
}
/* Carousel */
.carousel {
position: relative;
width: 100%;
aspect-ratio: 16/9;
overflow: hidden;
border-radius: 6px;
margin-bottom: 32px;
}
.carousel-track {
display: flex;
height: 100%;
transition: transform 0.5s ease;
}
.carousel-slide {
min-width: 100%;
height: 100%;
background-size: cover;
background-position: center;
}
.carousel-dots {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 32px;
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
border: none;
padding: 0;
cursor: pointer;
transition: background 0.2s;
}
.carousel-dot.active {
background: var(--accent);
}
/* Hero - Block 1 */
.hero-sources {
font-size: 1.1rem;
font-weight: 300;
color: var(--text-muted);
line-height: 1.9;
margin-bottom: 32px;
}
.hero-sources span { display: block; }
.hero-sources .different {
}
.hero-pivot {
font-size: 1.15rem;
font-weight: 400;
color: var(--text);
line-height: 1.8;
margin-bottom: 32px;
}
.hero-pivot span { display: block; }
.hero-pivot .emphasis {
font-size: 1.3rem;
font-weight: 600;
margin-top: 12px;
}
.hero-answer {
text-align: center;
font-size: 1.7rem;
font-weight: 500;
color: var(--text);
line-height: 1.5;
margin-top: 16px;
margin-bottom: 8px;
}
.hero-answer .inou {
font-weight: 700;
color: var(--accent);
}
.hero-tagline {
text-align: center;
font-size: 2.8rem;
font-weight: 700;
color: var(--text);
margin-bottom: 12px;
}
.carousel-caption {
text-align: center;
font-size: 0.95rem;
color: var(--muted);
line-height: 1.5;
min-height: 3em;
padding: 0 24px;
margin-bottom: 24px;
}
.hero-cta { margin-bottom: 0; text-align: center; }
.hero-cta .btn { padding: 18px 56px; font-size: 0.9rem; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; border-radius: 4px; }
.story-prose.warm { font-size: 1.1rem; line-height: 1.8; color: var(--text); }
.story-prose.warm p { margin-bottom: 20px; }
.story-prose.warm .emphasis { font-weight: 600; font-size: 1.15rem; }
.story-title { font-size: 1.25rem; font-weight: 600; color: var(--text); margin-bottom: 32px; }
.story-pair { margin-bottom: 32px; }
.story-pair .data { font-size: 1.1rem; font-weight: 400; color: var(--text); margin-bottom: 4px; }
.story-pair .reality { font-size: 1rem; font-weight: 300; font-style: italic; color: var(--text-muted); }
.story-transition { font-size: 1.25rem; font-weight: 400; color: var(--text); line-height: 1.8; margin: 32px 0; padding: 24px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.story-gaps { font-size: 1rem; font-weight: 300; color: var(--text-muted); line-height: 1.8; margin-bottom: 32px; }
.hero-cta .btn {
padding: 18px 56px;
font-size: 0.9rem;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
border-radius: 4px;
}
/* Story - Block 2 */
.story-prose.warm {
font-size: 1.1rem;
line-height: 1.8;
color: var(--text);
}
.story-prose.warm p {
margin-bottom: 20px;
}
.story-prose.warm .emphasis {
font-weight: 600;
font-size: 1.15rem;
}
.story-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
margin-bottom: 32px;
}
.story-pair {
margin-bottom: 32px;
}
.story-pair .data {
font-size: 1.1rem;
font-weight: 400;
color: var(--text);
margin-bottom: 4px;
}
.story-pair .reality {
font-size: 1rem;
font-weight: 300;
font-style: italic;
color: var(--text-muted);
}
.story-transition {
font-size: 1.25rem;
font-weight: 400;
color: var(--text);
line-height: 1.8;
margin: 32px 0;
padding: 24px 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.story-gaps {
font-size: 1rem;
font-weight: 300;
color: var(--text-muted);
line-height: 1.8;
margin-bottom: 32px;
}
.story-gaps span { display: block; }
.story-gaps .indent { font-style: italic; }
.story-connections { font-size: 1rem; font-weight: 300; color: var(--text-muted); line-height: 1.8; margin-bottom: 32px; }
.story-gaps .indent { font-style: italic; }
.story-connections {
font-size: 1rem;
font-weight: 300;
color: var(--text-muted);
line-height: 1.8;
margin-bottom: 32px;
}
.story-connections span { display: block; }
.story-ai { font-size: 1.25rem; font-weight: 400; color: var(--text); line-height: 1.8; margin-bottom: 32px; }
.story-ai {
font-size: 1.25rem;
font-weight: 400;
color: var(--text);
line-height: 1.8;
margin-bottom: 32px;
}
.story-ai span { display: block; }
.story-ai .last { font-style: italic; }
.story-prose { font-size: 1rem; font-weight: 300; color: var(--text-muted); line-height: 1.8; margin-bottom: 20px; }
.story-ai .last {
font-style: italic;
}
.story-prose {
font-size: 1rem;
font-weight: 300;
color: var(--text-muted);
line-height: 1.8;
margin-bottom: 20px;
}
.story-prose:last-of-type { margin-bottom: 32px; }
.story-prose strong { font-weight: 600; color: var(--text); }
.story-prose .inou { font-weight: 700; color: var(--accent); }
.story-closing { font-size: 1.25rem; font-weight: 400; color: var(--text); padding-top: 24px; border-top: 1px solid var(--border); }
.story-closing .inou { font-weight: 700; color: var(--accent); }
.trust-card { width: 100%; margin-left: auto; margin-right: auto; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 32px 48px; margin-bottom: 24px; }
.trust-card .section-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 24px; }
.trust-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 32px; }
.trust-item { font-size: 0.9rem; font-weight: 300; color: var(--text-muted); line-height: 1.6; }
.trust-item strong { display: block; font-weight: 600; color: var(--text); margin-bottom: 4px; }
.landing-footer { padding: 16px 0; border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
.landing-footer-left { font-size: 0.9rem; color: var(--text-muted); display: flex; gap: 16px; align-items: center; }
.landing-footer-left a { color: var(--text-muted); text-decoration: none; }
.story-closing {
font-size: 1.25rem;
font-weight: 400;
color: var(--text);
padding-top: 24px;
border-top: 1px solid var(--border);
}
.story-closing .inou {
font-weight: 700;
color: var(--accent);
}
/* Trust section */
.trust-card {
width: 100%;
margin-left: auto;
margin-right: auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 32px 48px;
margin-bottom: 24px;
}
.trust-card .section-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 24px;
}
.trust-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 32px;
}
.trust-item {
font-size: 0.9rem;
font-weight: 300;
color: var(--text-muted);
line-height: 1.6;
}
.trust-item strong {
display: block;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
/* Footer */
.landing-footer {
padding: 16px 0;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.landing-footer-left {
font-size: 0.9rem;
color: var(--text-muted);
display: flex;
gap: 16px;
align-items: center;
}
.landing-footer-left a {
color: var(--text-muted);
text-decoration: none;
}
.landing-footer-left a:hover { color: var(--accent); }
.landing-footer-right { font-size: 1rem; }
.landing-footer-right .inou { font-weight: 700; color: var(--accent); }
.landing-footer-right .health { font-weight: 400; color: var(--text-muted); }
@media (max-width: 768px) { .trust-card { padding: 24px; } .trust-grid { grid-template-columns: repeat(2, 1fr); gap: 24px; } }
@media (max-width: 480px) { .trust-card { padding: 20px 16px; } .trust-grid { grid-template-columns: 1fr; gap: 20px; } .landing-footer { flex-direction: column; gap: 12px; text-align: center; } .landing-footer-left { flex-direction: column; gap: 8px; } }
.landing-footer-right .inou {
font-weight: 700;
color: var(--accent);
}
.landing-footer-right .health {
font-weight: 400;
color: var(--text-muted);
}
/* Mobile */
@media (max-width: 768px) {
.trust-card {
width: 100%;
margin-left: auto;
margin-right: auto; padding: 24px; }
.hero-sources {
font-size: 1rem; line-height: 1.8; margin-bottom: 32px; }
.hero-pivot { font-size: 1.1rem; margin-bottom: 32px;
}
.hero-pivot .emphasis { font-size: 1.3rem; }
.hero-answer {
text-align: center; font-size: 1.2rem; margin-top: 16px;
margin-bottom: 8px; }
.hero-tagline { font-size: 2rem; margin-bottom: 8px; }
.carousel-caption { font-size: 0.85rem; }
.hero-cta .btn { padding: 14px 40px; }
.story-pair .data { font-size: 1rem; }
.story-pair .reality { font-size: 0.95rem; }
.trust-grid { grid-template-columns: repeat(2, 1fr); gap: 24px; }
}
@media (max-width: 480px) {
.trust-card {
width: 100%;
margin-left: auto;
margin-right: auto; padding: 20px 16px; }
.hero-sources {
font-size: 0.95rem; line-height: 1.9; }
.hero-pivot { font-size: 1rem; }
.hero-pivot .emphasis { font-size: 1.2rem; }
.story-pair { margin-bottom: 24px; }
.trust-grid { grid-template-columns: 1fr; gap: 20px; }
.landing-footer { flex-direction: column; gap: 12px; text-align: center; }
.landing-footer-left { flex-direction: column; gap: 8px; }
}
</style>
<div class="sg-container">
<div class="landing-card">
<div class="hero">
<div class="hero-answer"><span class="inou">inou</span> organise et partage votre dossier santé avec votre IA — en toute sécurité et confidentialité.</div>
<div class="hero-tagline">Votre santé, comprise.</div>
<div class="hero-cta">{{if .Dossier}}<a href="/invite" class="btn btn-primary">Inviter un ami</a>{{else}}<a href="/start" class="btn btn-primary">Se connecter</a>{{end}}{{if .Error}}<div class="error" style="margin-top: 24px;">{{.Error}}</div>{{end}}</div>
<div class="hero-tagline">Ta santé, comprise.</div>
<div class="hero-answer">Toutes tes données de santé — organisées, privées, et prêtes pour ton IA.</div>
<div class="carousel">
<div class="carousel-track">
<div class="carousel-slide" style="background-image: url('/static/carousel-1.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-2.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-3.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-4.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-5.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-6.webp')"></div>
</div>
</div>
<div class="carousel-caption" id="carousel-caption">Suis l'évolution de tes analyses dans le temps — vois exactement ce que ton IA voit quand elle détecte un changement.</div>
<div class="carousel-dots">
<button class="carousel-dot active" data-index="0"></button>
<button class="carousel-dot" data-index="1"></button>
<button class="carousel-dot" data-index="2"></button>
<button class="carousel-dot" data-index="3"></button>
<button class="carousel-dot" data-index="4"></button>
<button class="carousel-dot" data-index="5"></button>
</div>
<div class="hero-cta">
{{if .Dossier}}<a href="/invite" class="btn btn-primary">Inviter un ami</a>{{else}}<a href="/start" class="btn btn-primary">Se connecter</a>{{end}}
{{if .Error}}<div class="error" style="margin-top: 24px;">{{.Error}}</div>{{end}}
</div>
</div>
</div>
<div class="landing-card">
<div class="story">
<h2 class="story-title">Vous avez besoin de l'IA pour votre santé</h2>
<h2 class="story-title">Tu as besoin d'une IA pour ta santé</h2>
<div class="story-prose warm">
<p>Vos données de santé sont dispersées dans des dizaines d'endroits — chez votre cardiologue, votre neurologue, le laboratoire, votre montre connectée, vos applications, votre 23andMe. Et vous seul connaissez le reste : ce que vous mangez, ce que vous buvez, quels compléments vous prenez. Votre programme d'entraînement. Vos symptômes. Vos objectifs — que vous essayiez de tomber enceinte, de vous préparer pour un marathon, ou simplement de vous sentir moins fatigué.</p>
<p>Que vous soyez en bonne santé et vouliez le rester, que vous naviguiez un diagnostic difficile, ou que vous vous occupiez d'un proche qui ne peut pas se défendre seul — aucun médecin ne voit le tableau complet. Aucun système ne connecte tout.</p>
<p>Mais vous avez accès à tout. Il vous manque juste l'expertise pour tout comprendre.</p>
<p class="emphasis">Votre IA l'a. inou lui donne le tableau complet.</p>
<p>Tes données de santé sont dispersées dans une dizaines d'endroits différents — chez ton cardiologue, ton neurologue, ton laboratoire, ta montre, tes applications, ton 23andMe. Et toi seul connais le reste : ce que tu manges, ce que tu bois, les suppléments que tu prends. Ton programme d'exercice. Tes symptômes. Tes objectifs — que tu tries de tomber enceinte, de préparer un marathon, ou simplement d'essayer de te sentir moins épuisé.</p>
<p>Que tu sois en bonne santé et veuille le rester, que tu navigues un diagnostic difficile, ou que tu prennes soin d'un membre de famille qui ne peut pas s'exprimer pour lui-même — aucun médecin ne voit l'image complète. Aucun système ne connecte tout ça.</p>
<p>Mais <strong><em>toi</em></strong> tu as accès à tout ça. Tu n'as juste pas l'expertise pour donner sens à tout ça.</p>
<p class="emphasis">Ton IA, si. inou lui donne l'image complète.</p>
</div>
</div>
</div>
<div class="landing-card">
<div class="story">
<h2 class="story-title">Le défi</h2>
<div class="story-pair"><div class="data">Votre IRM contient 4 000 coupes.</div><div class="reality">Elle a été lue en 10 minutes.</div></div>
<div class="story-pair"><div class="data">Votre génome contient des millions de variants.</div><div class="reality">Vous n'avez appris que la couleur de vos yeux et l'origine de vos ancêtres.</div></div>
<div class="story-pair"><div class="data">Votre bilan sanguin contient des dizaines de marqueurs.</div><div class="reality">Votre médecin a dit "tout va bien."</div></div>
<div class="story-pair"><div class="data">Votre montre a enregistré 10 000 heures de sommeil.</div><div class="reality">Votre coach ne sait pas qu'elle existe.</div></div>
<div class="story-pair"><div class="data">Vous avez essayé une centaine de compléments différents.</div><div class="reality">Personne n'a demandé lesquels.</div></div>
<div class="story-transition">Les connexions sont là.<br>Elles sont juste trop complexes pour une seule personne.</div>
<div class="story-pair">
<div class="data">Ton IRM a 4 000 coupes.</div>
<div class="reality">Elle a été analysée en 10 minutes.</div>
</div>
<div class="story-pair">
<div class="data">Ton génome contient des millions de variants.</div>
<div class="reality">Tout ce que tu as appris, c'est la couleur de tes yeux et d'où viennent tes ancêtres.</div>
</div>
<div class="story-pair">
<div class="data">Tes analyses de sang contiennent des dizaines de marqueurs.</div>
<div class="reality">Ton médecin a dit "tout va bien".</div>
</div>
<div class="story-pair">
<div class="data">Ta montre a suivi 10 000 heures de sommeil.</div>
<div class="reality">Ton coach ne sait même pas que ça existe.</div>
</div>
<div class="story-pair">
<div class="data">Tu as essayé des centaines de suppléments différents.</div>
<div class="reality">Personne n'a demandé lesquels.</div>
</div>
<div class="story-transition">
Les connexions existent.<br>
Elles sont juste trop complexes pour qu'une seule personne les saisisse.
</div>
<div class="story-gaps">
<span>Personne ne sait comment votre corps métabolise la Warfarine — pas même vous.</span>
<span class="indent">Mais la réponse se cache peut-être déjà dans votre 23andMe.</span>
<span>Ce "sans particularité" sur votre IRM — quelqu'un a-t-il vraiment regardé les 4 000 coupes attentivement ?</span>
<span>Votre thyroïde est "dans les normes" — mais personne n'a fait le lien avec votre fatigue, votre poids, le fait que vous avez toujours froid.</span>
<span>Personne ne sait comment ton corps traite la Warfarine — pas même toi.</span>
<span class="indent">Mais la réponse est peut-être déjà cachée dans ton 23andMe.</span>
<span>Ce "sans particularité" sur ton IRM — quelqu'un a-t-il vraiment regardé les 4 000 coupes de près ?</span>
<span>Ta thyroïde est "dans les normes" — mais personne ne l'a connectée à ta fatigue, ton poids, le fait d'avoir toujours froid.</span>
</div>
<div class="story-connections">
<span>Personne ne relie votre café de l'après-midi à votre qualité de sommeil.</span>
<span>Votre taux de fer à votre fatigue à l'entraînement.</span>
<span>Votre génétique à votre brouillard mental.</span>
<span>Personne ne connecte ta caféine de l'après-midi à tes scores de sommeil.</span>
<span>Ton taux de fer à ta fatigue à l'entraînement.</span>
<span>Ta génétique à ta brouillard mental.</span>
</div>
<div class="story-ai">
<span>Votre IA n'oublie pas.</span>
<span>Ne se précipite pas.</span>
<span>Ton IA n'oublie pas.</span>
<span>Ne se dépêche pas.</span>
<span>Trouve ce qui a été manqué.</span>
<span class="last">Ne se spécialise pas — vous voit dans votre globalité.</span>
<span class="last">Ne se spécialise pas — voit le toi complet.</span>
</div>
<div class="story-closing"><span class="inou">inou</span> permet à votre IA de tout prendre en compte — chaque coupe, chaque marqueur, chaque variant — de tout connecter et de vous donner enfin des réponses que personne d'autre ne pouvait donner.</div>
<div class="story-closing"><span class="inou">inou</span> permet à ton IA de tout prendre en compte — chaque coupe, chaque marqueur, chaque variant — tout connecter et enfin te donner des réponses que personne d'autre ne pourrait.</div>
</div>
</div>
<div class="landing-card">
<div class="story">
<h2 class="story-title">Pourquoi nous avons créé ça</h2>
<p class="story-prose">Vous avez collecté des années de données de santé. Des examens de l'hôpital. Des analyses du laboratoire. Des résultats du portail patient. Des données de votre montre. Peut-être même votre ADN.</p>
<p class="story-prose">Et puis il y a tout ce que vous seul savez — votre poids, votre tension, votre programme d'entraînement, les compléments que vous prenez, les symptômes que vous oubliez toujours de mentionner.</p>
<p class="story-prose">Tout est là — mais dispersé dans des systèmes qui ne communiquent pas entre eux, chez des spécialistes qui ne voient que leur partie, ou enfermé dans votre propre tête.</p>
<p class="story-prose">Votre cardiologue ne sait pas ce que votre neurologue a trouvé. Votre coach n'a pas vu vos analyses sanguines. Votre médecin n'a aucune idée des compléments que vous prenez. Et aucun d'entre eux n'a le temps de s'asseoir avec vous pour relier les points.</p>
<p class="story-prose"><strong>L'IA peut enfin le faire.</strong> Elle peut rassembler ce qu'aucun expert seul ne voit — et vous l'expliquer en plus.</p>
<p class="story-prose">Mais ces données ne tiennent pas dans une fenêtre de chat. Et la dernière chose que vous voulez, c'est votre historique médical sur les serveurs de quelqu'un d'autre, entraînant leurs modèles.</p>
<p class="story-prose"><span class="inou">inou</span> rassemble tout — analyses, imagerie, génétique, constantes, médicaments, compléments — chiffré, privé, et partagé avec absolument personne. Votre IA se connecte en toute sécurité. Vos données restent les vôtres.</p>
<div class="story-closing">Votre santé, comprise.</div>
<h2 class="story-title">Pourquoi nous avons built this</h2>
<p class="story-prose">Tu as collecté des années de données de santé. Des scans de l'hôpital. Des analyses de sang du laboratoire. Les résultats du portail de ton médecin. Les données de ta montre. Peut-être même ton ADN.</p>
<p class="story-prose">Et puis il y a tout ce que toi seul sais — ton poids, ta tension artérielle, ton programme d'entraînement, les suppléments que tu prends, les symptômes que tu as mentionné.</p>
<p class="story-prose">Tout est là — mais dispersé à travers des systèmes qui ne communiquent pas entre eux, détenus par des spécialistes qui ne voient que leur partie, ou verrouillés dans ta propre tête.</p>
<p class="story-prose">Ton cardiologue ne sait pas ce que ton neurologue a trouvé. Ton coach n'a pas vu tes analyses de sang. Ton médecin n'a aucune idée des suppléments que tu prends. Et aucun d'eux n'a le temps de s'asseoir avec toi et de faire les liens.</p>
<p class="story-prose"><strong>L'IA enfin peut.</strong> Elle peut assembler ce qu'aucun expert seul ne voit — et te l'expliquer vraiment.</p>
<p class="story-prose">Mais ces données ne rentrent pas dans une fenêtre de chat. Et la dernière chose que tu veux, c'est ton historique médical sur les serveurs de quelqu'un d'autre, pour entraîner leurs modèles.</p>
<p class="story-prose"><span class="inou">inou</span> rassemble tout — laboratoires, imagerie, génétique, constantes vitales, médicaments, suppléments — chiffré, privé, et partagé avec absolument personne. Ton IA se connecte de manière sécurisée. Tes données restent tiennes.</p>
<div class="story-closing">Ta santé, comprise.</div>
</div>
</div>
<div class="trust-card">
<div class="section-label">{{.T.data_yours}}</div>
<div class="trust-grid">
<div class="trust-item"><strong>{{.T.never_training}}</strong>{{.T.never_training_desc}}</div>
<div class="trust-item"><strong>{{.T.never_shared}}</strong>{{.T.never_shared_desc}}</div>
<div class="trust-item"><strong>{{.T.encrypted}}</strong>{{.T.encrypted_desc}}</div>
<div class="trust-item"><strong>{{.T.delete}}</strong>{{.T.delete_desc}}</div>
<div class="trust-item">
<strong>{{.T.never_training}}</strong>
{{.T.never_training_desc}}
</div>
<div class="trust-item">
<strong>{{.T.never_shared}}</strong>
{{.T.never_shared_desc}}
</div>
<div class="trust-item">
<strong>{{.T.encrypted}}</strong>
{{.T.encrypted_desc}}
</div>
<div class="trust-item">
<strong>{{.T.delete}}</strong>
{{.T.delete_desc}}
</div>
</div>
</div>
<footer class="landing-footer">
<div class="landing-footer-left"><span>© 2025</span><a href="/privacy-policy">Confidentialité</a></div>
<span class="landing-footer-right"><span class="inou">inou</span> <span class="health">health</span></span>
</footer>
{{template "footer"}}
</div>
<script>
(function() {
var track = document.querySelector('.carousel-track');
var dots = document.querySelectorAll('.carousel-dot');
var caption = document.getElementById('carousel-caption');
var captions = [
'Suis l\'évolution de tes analyses dans le temps — vois exactement ce que ton IA voit quand elle détecte un changement.',
'Tes labos, tes scans et ton génome en un seul endroit — parcours tout ce à quoi ton IA a accès.',
'Vois ta propre IRM — zoome sur les mêmes coupes que ton IA a analysées.',
'Ton scan cérébral en 3D — navigue dans tous les plans, vérifie chaque finding que ton IA a fait.',
'Ton IA fait le lien entre les analyses et le génome — et t\'explique tout en langage clair.',
'Ta radio, pleine résolution — zoome sur les résultats que ton IA a signalés.'
];
var count = dots.length;
var current = 0;
function go(i) {
current = i;
track.style.transform = 'translateX(-' + (i * 100) + '%)';
dots.forEach(function(d, j) { d.classList.toggle('active', j === i); });
caption.textContent = captions[i];
}
dots.forEach(function(d) {
d.addEventListener('click', function() { go(+d.dataset.index); });
});
setInterval(function() { go((current + 1) % count); }, 8000);
})();
</script>
{{end}}

View File

@ -1,5 +1,6 @@
{{define "landing_nl"}}
<style>
.landing-card {
background: var(--bg-card);
border: 1px solid var(--border);
@ -12,23 +13,106 @@
margin-bottom: 24px;
}
.hero-answer {
text-align: center;
font-size: 1.25rem;
/* Carousel */
.carousel {
position: relative;
width: 100%;
aspect-ratio: 16/9;
overflow: hidden;
border-radius: 6px;
margin-bottom: 32px;
}
.carousel-track {
display: flex;
height: 100%;
transition: transform 0.5s ease;
}
.carousel-slide {
min-width: 100%;
height: 100%;
background-size: cover;
background-position: center;
}
.carousel-dots {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 32px;
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
border: none;
padding: 0;
cursor: pointer;
transition: background 0.2s;
}
.carousel-dot.active {
background: var(--accent);
}
/* Hero - Block 1 */
.hero-sources {
font-size: 1.1rem;
font-weight: 300;
color: var(--text-muted);
line-height: 1.9;
margin-bottom: 32px;
}
.hero-sources span { display: block; }
.hero-sources .different {
}
.hero-pivot {
font-size: 1.15rem;
font-weight: 400;
color: var(--text);
line-height: 1.8;
margin-top: 16px;
margin-bottom: 32px;
}
.hero-pivot span { display: block; }
.hero-pivot .emphasis {
font-size: 1.3rem;
font-weight: 600;
margin-top: 12px;
}
.hero-answer {
text-align: center;
font-size: 1.7rem;
font-weight: 500;
color: var(--text);
line-height: 1.5;
margin-top: 16px;
margin-bottom: 8px;
}
.hero-answer .inou {
font-weight: 700;
color: var(--accent);
}
.hero-answer .inou { font-weight: 700; color: var(--accent); }
.hero-tagline {
text-align: center;
font-size: 1.3rem;
font-weight: 600;
font-size: 2.8rem;
font-weight: 700;
color: var(--text);
margin-bottom: 32px;
margin-bottom: 12px;
}
.carousel-caption {
text-align: center;
font-size: 0.95rem;
color: var(--muted);
line-height: 1.5;
min-height: 3em;
padding: 0 24px;
margin-bottom: 24px;
}
.hero-cta { margin-bottom: 0; text-align: center; }
@ -41,13 +125,20 @@
border-radius: 4px;
}
/* Story - Block 2 */
.story-prose.warm {
font-size: 1.1rem;
line-height: 1.8;
color: var(--text);
}
.story-prose.warm p { margin-bottom: 20px; }
.story-prose.warm .emphasis { font-weight: 600; font-size: 1.15rem; }
.story-prose.warm p {
margin-bottom: 20px;
}
.story-prose.warm .emphasis {
font-weight: 600;
font-size: 1.15rem;
}
.story-title {
font-size: 1.25rem;
@ -56,7 +147,9 @@
margin-bottom: 32px;
}
.story-pair { margin-bottom: 32px; }
.story-pair {
margin-bottom: 32px;
}
.story-pair .data {
font-size: 1.1rem;
font-weight: 400;
@ -67,6 +160,7 @@
font-size: 1rem;
font-weight: 300;
font-style: italic;
color: var(--text-muted);
}
@ -89,7 +183,7 @@
margin-bottom: 32px;
}
.story-gaps span { display: block; }
.story-gaps .indent { font-style: italic; }
.story-gaps .indent { font-style: italic; }
.story-connections {
font-size: 1rem;
@ -108,7 +202,9 @@
margin-bottom: 32px;
}
.story-ai span { display: block; }
.story-ai .last { font-style: italic; }
.story-ai .last {
font-style: italic;
}
.story-prose {
font-size: 1rem;
@ -128,8 +224,12 @@
padding-top: 24px;
border-top: 1px solid var(--border);
}
.story-closing .inou { font-weight: 700; color: var(--accent); }
.story-closing .inou {
font-weight: 700;
color: var(--accent);
}
/* Trust section */
.trust-card {
width: 100%;
@ -167,6 +267,7 @@
margin-bottom: 4px;
}
/* Footer */
.landing-footer {
padding: 16px 0;
border-top: 1px solid var(--border);
@ -181,26 +282,55 @@
gap: 16px;
align-items: center;
}
.landing-footer-left a { color: var(--text-muted); text-decoration: none; }
.landing-footer-left a {
color: var(--text-muted);
text-decoration: none;
}
.landing-footer-left a:hover { color: var(--accent); }
.landing-footer-right { font-size: 1rem; }
.landing-footer-right .inou { font-weight: 700; color: var(--accent); }
.landing-footer-right .health { font-weight: 400; color: var(--text-muted); }
.landing-footer-right .inou {
font-weight: 700;
color: var(--accent);
}
.landing-footer-right .health {
font-weight: 400;
color: var(--text-muted);
}
/* Mobile */
@media (max-width: 768px) {
.trust-card {
width: 100%;
margin-left: auto;
margin-right: auto; padding: 24px; }
.hero-sources {
font-size: 1rem; line-height: 1.8; margin-bottom: 32px; }
.hero-pivot { font-size: 1.1rem; margin-bottom: 32px;
}
.hero-pivot .emphasis { font-size: 1.3rem; }
.hero-answer {
text-align: center; font-size: 1.2rem; margin-top: 16px;
margin-bottom: 8px; }
.hero-tagline { font-size: 2rem; margin-bottom: 8px; }
.carousel-caption { font-size: 0.85rem; }
.hero-cta .btn { padding: 14px 40px; }
.story-pair .data { font-size: 1rem; }
.story-pair .reality { font-size: 0.95rem; }
.trust-grid { grid-template-columns: repeat(2, 1fr); gap: 24px; }
}
@media (max-width: 480px) {
.trust-card {
width: 100%;
margin-left: auto;
margin-right: auto; padding: 20px 16px; }
.hero-sources {
font-size: 0.95rem; line-height: 1.9; }
.hero-pivot { font-size: 1rem; }
.hero-pivot .emphasis { font-size: 1.2rem; }
.story-pair { margin-bottom: 24px; }
.trust-grid { grid-template-columns: 1fr; gap: 20px; }
.landing-footer { flex-direction: column; gap: 12px; text-align: center; }
.landing-footer-left { flex-direction: column; gap: 8px; }
@ -211,10 +341,29 @@
<div class="landing-card">
<div class="hero">
<div class="hero-answer"><span class="inou">inou</span> organiseert en deelt je gezondheidsdossier met je AI — veilig en privé.</div>
<div class="hero-tagline">Je gezondheid, begrepen.</div>
<div class="hero-tagline">Je gezondheid, doorgrond.</div>
<div class="hero-answer">Al je gezondheidsdata — georganiseerd, privé, en klaar voor je AI.</div>
<div class="carousel">
<div class="carousel-track">
<div class="carousel-slide" style="background-image: url('/static/carousel-1.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-2.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-3.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-4.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-5.webp')"></div>
<div class="carousel-slide" style="background-image: url('/static/carousel-6.webp')"></div>
</div>
</div>
<div class="carousel-caption" id="carousel-caption">Volg je labtrends over tijd — zie precies wat je AI ziet wanneer het een verandering signaleert.</div>
<div class="carousel-dots">
<button class="carousel-dot active" data-index="0"></button>
<button class="carousel-dot" data-index="1"></button>
<button class="carousel-dot" data-index="2"></button>
<button class="carousel-dot" data-index="3"></button>
<button class="carousel-dot" data-index="4"></button>
<button class="carousel-dot" data-index="5"></button>
</div>
<div class="hero-cta">
{{if .Dossier}}<a href="/invite" class="btn btn-primary">Vriend uitnodigen</a>{{else}}<a href="/start" class="btn btn-primary">Inloggen</a>{{end}}
{{if .Dossier}}<a href="/invite" class="btn btn-primary">Nodig een vriend uit</a>{{else}}<a href="/start" class="btn btn-primary">Inloggen</a>{{end}}
{{if .Error}}<div class="error" style="margin-top: 24px;">{{.Error}}</div>{{end}}
</div>
</div>
@ -225,32 +374,31 @@
<h2 class="story-title">Je hebt AI nodig voor je gezondheid</h2>
<div class="story-prose warm">
<p>Je gezondheidsgegevens liggen verspreid over tientallen plekken — bij je cardioloog, je neuroloog, het lab, je smartwatch, je apps, je 23andMe. En dan is er nog alles wat alleen jij weet: wat je eet, wat je drinkt, welke supplementen je slikt. Je trainingsschema. Je klachten. Je doelen — of je nu zwanger wilt worden, traint voor een marathon, of gewoon minder moe wilt zijn.</p>
<p>Je gezondheidsdata ligt verspreid over tientallen plekken — bij je cardioloog, je neuroloog, je lab, je horloge, je apps, je 23andMe. En alleen jij weet de rest: wat je eet, wat je drinkt, welke supplementen je slikt. Je trainingsschema. Je klachten. Je doelen — of je nu zwanger probeert te worden, traint voor een marathon, of gewoon minder moe wilt zijn.</p>
<p>Of je nu gezond bent en dat wilt blijven, worstelt met een lastige diagnose, of zorgt voor een familielid dat niet voor zichzelf kan opkomen — geen enkele arts ziet het complete plaatje. Geen enkel systeem verbindt het.</p>
<p>Of je nu gezond bent en dat wilt blijven, midden in een lastig diagnosetraject zit, of zorgt voor een familielid dat niet voor zichzelf kan opkomen — geen enkele arts ziet het volledige plaatje. Geen enkel systeem verbindt het.</p>
<p>Maar jij hebt toegang tot alles. Je mist alleen de expertise om er iets van te maken.</p>
<p>Maar <strong><em>jij</em></strong> hebt toegang tot alles. Je hebt alleen niet de expertise om er iets van te maken.</p>
<p class="emphasis">Je AI wel. inou geeft het het complete plaatje.</p>
<p class="emphasis">Je AI wel. inou geeft het het volledige plaatje.</p>
</div>
</div>
</div>
<div class="landing-card">
<div class="story">
<h2 class="story-title">De uitdaging</h2>
<div class="story-pair">
<div class="data">Je MRI heeft 4.000 beelden.</div>
<div class="reality">Die werd in 10 minuten beoordeeld.</div>
<div class="data">Je MRI heeft 4.000 slices.</div>
<div class="reality">Hij werd in 10 minuten beoordeeld.</div>
</div>
<div class="story-pair">
<div class="data">Je genoom heeft miljoenen varianten.</div>
<div class="reality">Je leerde alleen je oogkleur en waar je voorouders vandaan kwamen.</div>
<div class="data">Je genoom bevat miljoenen varianten.</div>
<div class="reality">Je hoorde alleen je oogkleur en waar je voorouders vandaan kwamen.</div>
</div>
<div class="story-pair">
<div class="data">Je bloedonderzoek heeft tientallen markers.</div>
<div class="data">Je bloedonderzoek bevat tientallen markers.</div>
<div class="reality">Je arts zei "alles ziet er goed uit."</div>
</div>
@ -260,7 +408,7 @@
</div>
<div class="story-pair">
<div class="data">Je hebt honderd verschillende supplementen geprobeerd.</div>
<div class="data">Je hebt honderden supplementen geprobeerd.</div>
<div class="reality">Niemand vroeg welke.</div>
</div>
@ -272,46 +420,47 @@
<div class="story-gaps">
<span>Niemand weet hoe jouw lichaam Warfarine verwerkt — jijzelf ook niet.</span>
<span class="indent">Maar het antwoord zit misschien al in je 23andMe.</span>
<span>Die 'geen bijzonderheden' op je MRI — heeft iemand echt alle 4.000 beelden bekeken?</span>
<span>Die 'niet-afwijkend' op je MRI — heeft iemand echt alle 4.000 slices bekeken?</span>
<span>Je schildklier is 'binnen de norm' — maar niemand legde het verband met je vermoeidheid, je gewicht, dat je het altijd koud hebt.</span>
</div>
<div class="story-connections">
<span>Niemand verbindt je middagkoffie aan je slaapkwaliteit.</span>
<span>Je ijzergehalte aan je sportvermoeidheid.</span>
<span>Je genetica aan je brain fog.</span>
<span>Niemand verbindt je middagcafeïne met je slaapscores.</span>
<span>Je ijzerwaarden met je trainingsvermoeidheid.</span>
<span>Je genetica met je brain fog.</span>
</div>
<div class="story-ai">
<span>Je AI vergeet niet.</span>
<span>Haast niet.</span>
<span>Vindt wat gemist werd.</span>
<span class="last">Specialiseert niet — ziet de complete jij.</span>
<span>Je AI vergeet niets.</span>
<span>Heeft geen haast.</span>
<span>Vindt wat gemist is.</span>
<span class="last">Specialiseert niet — ziet het complete plaatje.</span>
</div>
<div class="story-closing"><span class="inou">inou</span> laat je AI alles meewegen — elk beeld, elke marker, elke variant — verbindt alles en geeft je eindelijk antwoorden die niemand anders kon geven.</div>
<div class="story-closing"><span class="inou">inou</span> laat je AI alles meewegen — elke slice, elke marker, elke variant — verbindt het allemaal en geeft je eindelijk antwoorden die niemand anders kon geven.</div>
</div>
</div>
<div class="landing-card">
<div class="story">
<h2 class="story-title">Waarom we dit bouwden</h2>
<h2 class="story-title">Waarom we dit gebouwd hebben</h2>
<p class="story-prose">Je hebt jarenlang gezondheidsgegevens verzameld. Scans van het ziekenhuis. Bloedonderzoek van het lab. Uitslagen uit het patiëntenportaal. Data van je horloge. Misschien zelfs je DNA.</p>
<p class="story-prose">Je hebt jarenlang gezondheidsdata verzameld. Scans van het ziekenhuis. Bloedonderzoek van het lab. Uitslagen via het patiëntenportaal. Data van je horloge. Misschien zelfs je DNA.</p>
<p class="story-prose">En dan is er nog alles wat alleen jij weet — je gewicht, je bloeddruk, je trainingsschema, de supplementen die je slikt, de klachten die je steeds vergeet te noemen.</p>
<p class="story-prose">En dan is er alles wat alleen jij weet — je gewicht, je bloeddruk, je trainingsschema, de supplementen die je slikt, de klachten die je steeds vergeet te noemen.</p>
<p class="story-prose">Het is er allemaal — maar verspreid over systemen die niet met elkaar praten, bij specialisten die alleen hun eigen stukje zien, of opgesloten in je eigen hoofd.</p>
<p class="story-prose">Het is er allemaal — maar verspreid over systemen die niet met elkaar praten, vastgehouden door specialisten die alleen hun eigen stukje zien, of opgesloten in je eigen hoofd.</p>
<p class="story-prose">Je cardioloog weet niet wat je neuroloog vond. Je trainer heeft je bloedonderzoek niet gezien. Je huisarts heeft geen idee welke supplementen je slikt. En geen van hen heeft tijd om met je te zitten en de puzzel te leggen.</p>
<p class="story-prose">Je cardioloog weet niet wat je neuroloog gevonden heeft. Je trainer heeft je bloedonderzoek niet gezien. Je huisarts heeft geen idee welke supplementen je slikt. En geen van hen heeft de tijd om met je te zitten en de puzzelstukjes te verbinden.</p>
<p class="story-prose"><strong>AI kan dat eindelijk.</strong> Het kan samenbrengen wat geen enkele expert ziet — en het je ook nog uitleggen.</p>
<p class="story-prose"><strong>AI kan dat eindelijk wel.</strong> Het kan samenbrengen wat geen enkele specialist overziet — en het je ook nog eens uitleggen.</p>
<p class="story-prose">Maar deze data past niet in een chatvenster. En het laatste wat je wilt is je medische geschiedenis op andermans servers, gebruikt om hun modellen te trainen.</p>
<p class="story-prose"><span class="inou">inou</span> brengt alles samen — labs, beeldvorming, genetica, vitals, medicatie, supplementen — versleuteld, privé, en met niemand gedeeld. Je AI verbindt veilig. Je data blijft van jou.</p>
<p class="story-prose"><span class="inou">inou</span> brengt alles samen — labresultaten, beeldvorming, genetica, vitale functies, medicatie, supplementen — versleuteld, privé, en met absoluut niemand gedeeld. Je AI maakt een beveiligde verbinding. Je data blijft van jou.</p>
<div class="story-closing">Je gezondheid, begrepen.</div>
<div class="story-closing">Je gezondheid, doorgrond.</div>
</div>
</div>
@ -340,4 +489,31 @@
{{template "footer"}}
</div>
<script>
(function() {
var track = document.querySelector('.carousel-track');
var dots = document.querySelectorAll('.carousel-dot');
var caption = document.getElementById('carousel-caption');
var captions = [
'Volg je labtrends over tijd \u2014 zie precies wat je AI ziet wanneer het een verandering signaleert.',
'Je labs, scans en genoom op \u00e9\u00e9n plek \u2014 bekijk alles waar je AI toegang toe heeft.',
'Bekijk je eigen MRI \u2014 zoom in op dezelfde slices die je AI geanalyseerd heeft.',
'Je hersenscan in 3D \u2014 navigeer door elk vlak, controleer elke bevinding van je AI.',
'Je AI legt verbanden tussen labs en genoom \u2014 en legt het uit in begrijpelijke taal.',
'Je r\u00f6ntgenfoto, volledige resolutie \u2014 zoom in op de bevindingen die je AI heeft gesignaleerd.'
];
var count = dots.length;
var current = 0;
function go(i) {
current = i;
track.style.transform = 'translateX(-' + (i * 100) + '%)';
dots.forEach(function(d, j) { d.classList.toggle('active', j === i); });
caption.textContent = captions[i];
}
dots.forEach(function(d) {
d.addEventListener('click', function() { go(+d.dataset.index); });
});
setInterval(function() { go((current + 1) % count); }, 8000);
})();
</script>
{{end}}

View File

@ -77,6 +77,18 @@
font-weight: 400;
}
.tier-price s {
color: var(--text-muted);
font-weight: 400;
}
.tier-free {
font-size: 0.85rem;
font-weight: 600;
color: #28a745;
margin-top: 4px;
}
.pricing-table tbody tr {
border-bottom: 1px solid var(--border);
}
@ -110,12 +122,15 @@
.category-row {
background: var(--bg-secondary);
font-weight: 600;
font-weight: 700;
color: var(--text);
}
.category-row td {
padding: 12px 16px;
padding: 16px 16px;
font-size: 1.1rem;
border-left: 3px solid var(--accent);
letter-spacing: 0.02em;
}
/* Mobile */
@ -141,7 +156,7 @@
<div class="pricing-container">
<div class="pricing-header">
<h1>Pricing</h1>
<p class="tagline">Choose the plan that fits your health journey</p>
<p class="tagline">All tiers free until July 1, 2026. No credit card required.</p>
</div>
<div class="pricing-table-wrapper">
@ -155,11 +170,13 @@
</th>
<th class="tier-header">
<div class="tier-name">Optimize</div>
<div class="tier-price">$12<span class="small">/mo</span></div>
<div class="tier-price"><s>$12<span class="small">/mo</span></s></div>
<div class="tier-free">free till 7/1/26</div>
</th>
<th class="tier-header">
<div class="tier-name">Research</div>
<div class="tier-price">$35<span class="small">/mo</span></div>
<div class="tier-price"><s>$35<span class="small">/mo</span></s></div>
<div class="tier-free">free till 7/1/26</div>
</th>
</tr>
</thead>

View File

@ -184,6 +184,9 @@
<h3>FIPS 140-3 encryption.</h3>
<p>FIPS 140-3 is the US government standard for cryptographic security. Your files are encrypted using FIPS 140-3 validated cryptography — tested, audited, and certified by independent labs.</p>
<h3>Security incident notification.</h3>
<p>If a security breach occurs that affects your personal data, we will notify you by email within 72 hours of becoming aware of the incident. That notification will tell you what happened, which data was affected, what we did to contain it, and what steps you can take to protect yourself. We will also notify relevant data protection authorities as required by GDPR, FADP, and applicable law.</p>
<h3>Independent infrastructure.</h3>
<p>We don't run on Big Tech clouds. No Google. No Amazon. No Microsoft. Data is stored on servers in the United States. If you access <span class="inou-brand">inou</span> from outside the US, your data crosses international borders. We apply the same security and privacy protections regardless of your location.</p>
</div>
@ -235,7 +238,7 @@
<p>We may update this policy. Registered users will be notified by email of material changes. Continued use after changes constitutes acceptance.</p>
<p>Regardless of your jurisdiction, you may request access to your data, correction of inaccuracies, or complete deletion of your account. We will respond within 30 days.</p>
<p>Data Protection Officer: <a href="mailto:privacy@inou.com">privacy@inou.com</a></p>
<p>This policy was last updated on February 8, 2026.</p>
<p>This policy was last updated on March 10, 2026.</p>
</div>
{{template "footer"}}

View File

@ -12,6 +12,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
@ -123,6 +124,7 @@ func isGenomeData(data []byte) bool {
return false
}
var sliceRe = regexp.MustCompile(`^(.+?) - (.+?) - slice (\d+)/(\d+)`)
var fileRe = regexp.MustCompile(`^file (\d+)/(\d+)`)
// Upload represents a file upload entry for display
type Upload struct {
@ -182,8 +184,8 @@ func getUploads(dossierID string) []Upload {
Category: e.Type,
Status: status,
SizeHuman: formatBytes(int64(size)),
UploadedAt: time.Unix(e.Timestamp, 0).Format("Jan 2"),
ExpiresAt: time.Unix(e.TimestampEnd, 0).Format("Jan 2"),
UploadedAt: time.Unix(e.Timestamp, 0).UTC().Format("Jan 2"),
ExpiresAt: time.Unix(e.TimestampEnd, 0).UTC().Format("Jan 2"),
CanUndo: len(createdEntries) > 0,
}
if e.Status != 0 {
@ -399,18 +401,11 @@ func handleDeleteFile(w http.ResponseWriter, r *http.Request) {
}
}
// Get file info for audit and deletion
filePath, fileName, _, _, _ := getUploadEntry(fileID, targetID)
if filePath != "" {
os.Remove(filePath)
}
// Get file info for audit
_, fileName, _, _, _ := getUploadEntry(fileID, targetID)
// Mark as deleted using lib.EntryGet + lib.EntryWrite
entry, err := lib.EntryGet(nil, fileID) // nil ctx - internal operation
if err == nil && entry != nil && entry.DossierID == targetID {
entry.Status = 1 // Mark as deleted
lib.EntryWrite("", entry) // nil ctx - internal operation
}
// Delete the upload entry and its file
lib.EntryDelete("", targetID, &lib.Filter{EntryID: fileID})
lib.AuditLog(p.DossierID, "file_delete", targetID, fileName)
http.Redirect(w, r, fmt.Sprintf("/dossier/%s/upload", formatHexID(targetID)), http.StatusSeeOther)
@ -652,11 +647,11 @@ func runProcessImaging(actorID, targetID string) {
totalFiles := len(pendingDICOM)
logFn := func(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
if m := sliceRe.FindStringSubmatch(msg); m != nil {
processed++
if m := fileRe.FindStringSubmatch(msg); m != nil {
processed, _ = strconv.Atoi(m[1])
} else if m := sliceRe.FindStringSubmatch(msg); m != nil {
processProgress.Store(targetID, map[string]interface{}{
"stage": "importing", "study": m[1], "series": m[2],
"slice": m[3], "slice_total": m[4],
"processed": processed, "total": totalFiles,
})
}
@ -765,6 +760,7 @@ type extractedEntry struct {
Data map[string]interface{} `json:"data"`
DataTranslated map[string]interface{} `json:"data_translated,omitempty"`
SourceSpans []sourceSpan `json:"source_spans,omitempty"`
Results []extractedEntry `json:"results,omitempty"` // lab_order children
}
type sourceSpan struct {
@ -790,6 +786,7 @@ func langName(code string) string {
func extractionPreamble(targetLang string) string {
s := `IMPORTANT RULES (apply to all entries you return):
- Do NOT translate. Keep ALL text values (summary, value, data fields) in the ORIGINAL language of the document.
- Normalize ALL dates to "Mon YYYY" (e.g. "Nov 2025") or "DD Mon YYYY" (e.g. "21 Nov 2025") in summary fields. Never use numeric-only date formats like 11-21-25 or 11/21/2025.
- For each entry, include "source_spans": an array of {"start": "...", "end": "..."} where start/end are the VERBATIM first and last 5-8 words of the relevant passage(s) in the source markdown. This is used to highlight the source text. Multiple spans are allowed.
- For each entry, include "search_key": a short normalized deduplication key in English lowercase. Format: "thing:qualifier:YYYY-MM" or "thing:qualifier" for undated facts. Examples: "surgery:vp-shunt:2020-07", "device:ommaya-reservoir:2020-04", "diagnosis:hydrocephalus", "provider:peraud:ulm". Same real-world fact across different documents MUST produce the same key.
`
@ -826,6 +823,7 @@ func loadExtractionPrompts() map[int]string {
}
// parseTimestamp tries to parse a date string into Unix timestamp.
// Uses noon UTC to prevent date shift when displayed in negative-offset timezones.
func parseTimestamp(s string) int64 {
if s == "" {
return 0
@ -1016,7 +1014,7 @@ func processDocumentUpload(uploadID, dossierID, filePath, fileName string) {
msgs := []map[string]interface{}{
{"role": "user", "content": prompt},
}
resp, err := lib.CallFireworks(fireworksTextModel, msgs, 4096)
resp, err := lib.CallFireworks(fireworksTextModel, msgs, 8192)
if err != nil {
log.Printf("[doc-import] Category %d failed: %v", catID, err)
return
@ -1070,44 +1068,58 @@ func processDocumentUpload(uploadID, dossierID, filePath, fileName string) {
// 6. Create entries for each extracted item
var createdIDs []string
createEntry := func(dossierID, parentID string, category int, e extractedEntry) string {
dataMap := map[string]interface{}{"source_doc_id": docID}
for k, v := range e.Data {
dataMap[k] = v
}
if len(e.SourceSpans) > 0 {
dataMap["source_spans"] = e.SourceSpans
}
if e.SummaryTranslated != "" {
dataMap["summary_translated"] = e.SummaryTranslated
}
if len(e.DataTranslated) > 0 {
dataMap["data_translated"] = e.DataTranslated
}
dataJSON, _ := json.Marshal(dataMap)
ts := now
if parsed := parseTimestamp(e.Timestamp); parsed > 0 {
ts = parsed
}
entry := &lib.Entry{
DossierID: dossierID,
ParentID: parentID,
Category: category,
Type: e.Type,
Value: e.Value,
Summary: e.Summary,
SearchKey: e.SearchKey,
Timestamp: ts,
Data: string(dataJSON),
}
lib.EntryWrite("", entry)
createdIDs = append(createdIDs, entry.EntryID)
return entry.EntryID
}
for _, r := range results {
for _, e := range r.Entries {
// Build Data JSON with source reference + extracted fields
dataMap := map[string]interface{}{
"source_doc_id": docID,
if e.Type == "lab_order" && len(e.Results) > 0 {
// Hierarchical lab: create parent then children
orderID := createEntry(dossierID, docID, r.Category, extractedEntry{
Type: "lab_order", Value: e.Value, Timestamp: e.Timestamp, SourceSpans: e.SourceSpans,
SummaryTranslated: e.SummaryTranslated, DataTranslated: e.DataTranslated,
})
for _, child := range e.Results {
if child.Timestamp == "" {
child.Timestamp = e.Timestamp
}
createEntry(dossierID, orderID, r.Category, child)
}
} else {
createEntry(dossierID, docID, r.Category, e)
}
for k, v := range e.Data {
dataMap[k] = v
}
if len(e.SourceSpans) > 0 {
dataMap["source_spans"] = e.SourceSpans
}
if e.SummaryTranslated != "" {
dataMap["summary_translated"] = e.SummaryTranslated
}
if len(e.DataTranslated) > 0 {
dataMap["data_translated"] = e.DataTranslated
}
dataJSON, _ := json.Marshal(dataMap)
ts := now
if parsed := parseTimestamp(e.Timestamp); parsed > 0 {
ts = parsed
}
entry := &lib.Entry{
DossierID: dossierID,
ParentID: docID,
Category: r.Category,
Type: e.Type,
Value: e.Value,
Summary: e.Summary,
SearchKey: e.SearchKey,
Timestamp: ts,
Data: string(dataJSON),
}
lib.EntryWrite("", entry)
createdIDs = append(createdIDs, entry.EntryID)
}
}

View File

@ -51,7 +51,7 @@ fi
# Check for db.Exec outside allowed files (in lib only)
echo -n "Checking for db.Exec() outside allowed lib files... "
MATCHES=$(grep -r "db\.Exec" --include="*.go" lib 2>/dev/null | grep -v "lib/db_queries.go" | grep -v "lib/db_schema.go" | grep -v "lib/dbcore.go" | grep -v "lib/stubs.go" | grep -v "lib/migrate" || true)
MATCHES=$(grep -r "db\.Exec" --include="*.go" lib 2>/dev/null | grep -v "lib/db_queries.go" | grep -v "lib/db_schema.go" | grep -v "lib/dbcore.go" | grep -v "lib/stubs.go" | grep -v "lib/migrate" | grep -v "_test.go" || true)
if [ -n "$MATCHES" ]; then
echo -e "${RED}FAILED${NC}"
echo "$MATCHES"
@ -62,7 +62,7 @@ fi
# Check for db.Query outside allowed files (in lib only)
echo -n "Checking for db.Query() outside allowed lib files... "
MATCHES=$(grep -r "db\.Query" --include="*.go" lib 2>/dev/null | grep -v "lib/db_queries.go" | grep -v "lib/db_schema.go" | grep -v "lib/dbcore.go" | grep -v "lib/stubs.go" | grep -v "lib/migrate" || true)
MATCHES=$(grep -r "db\.Query" --include="*.go" lib 2>/dev/null | grep -v "lib/db_queries.go" | grep -v "lib/db_schema.go" | grep -v "lib/dbcore.go" | grep -v "lib/stubs.go" | grep -v "lib/migrate" | grep -v "_test.go" || true)
if [ -n "$MATCHES" ]; then
echo -e "${RED}FAILED${NC}"
echo "$MATCHES"
@ -73,7 +73,7 @@ fi
# Check for db.QueryRow outside allowed files (in lib only)
echo -n "Checking for db.QueryRow() outside allowed lib files... "
MATCHES=$(grep -r "db\.QueryRow" --include="*.go" lib 2>/dev/null | grep -v "lib/db_queries.go" | grep -v "lib/db_schema.go" | grep -v "lib/dbcore.go" | grep -v "lib/stubs.go" | grep -v "lib/migrate" || true)
MATCHES=$(grep -r "db\.QueryRow" --include="*.go" lib 2>/dev/null | grep -v "lib/db_queries.go" | grep -v "lib/db_schema.go" | grep -v "lib/dbcore.go" | grep -v "lib/stubs.go" | grep -v "lib/migrate" | grep -v "_test.go" || true)
if [ -n "$MATCHES" ]; then
echo -e "${RED}FAILED${NC}"
echo "$MATCHES"
@ -120,7 +120,7 @@ echo -n "Checking for CREATE TABLE in code... "
MATCHES=""
for dir in $CORE_DIRS; do
if [ -d "$dir" ]; then
FOUND=$(grep -ri "CREATE TABLE" --include="*.go" "$dir" 2>/dev/null | grep -v "lib/db_queries.go" | grep -v "rateDB" || true)
FOUND=$(grep -ri "CREATE TABLE" --include="*.go" "$dir" 2>/dev/null | grep -v "lib/db_queries.go" | grep -v "rateDB" | grep -v "_test.go" || true)
MATCHES="$MATCHES$FOUND"
fi
done

View File

@ -5,6 +5,9 @@ import (
"encoding/csv"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"strings"
@ -28,9 +31,14 @@ func main() {
os.Exit(1)
}
format := "json"
args := os.Args[1:]
if len(args) > 0 && (args[0] == "-serve" || args[0] == "--serve") {
serveHTTP()
return
}
format := "json"
if len(args) > 0 && (args[0] == "-csv" || args[0] == "--csv") {
format = "csv"
args = args[1:]
@ -58,28 +66,41 @@ func main() {
}
defer db.Close()
rows, err := db.Query(query)
cols, results, err := queryDB(db, query)
if err != nil {
fmt.Fprintf(os.Stderr, "query: %v\n", err)
os.Exit(1)
}
switch format {
case "csv":
outputCSV(cols, results)
case "table":
outputTable(cols, results)
default:
out, _ := json.MarshalIndent(results, "", " ")
fmt.Println(string(out))
}
}
func queryDB(db *sql.DB, query string) ([]string, []map[string]interface{}, error) {
rows, err := db.Query(query)
if err != nil {
return nil, nil, err
}
defer rows.Close()
cols, _ := rows.Columns()
colTypes, _ := rows.ColumnTypes()
var results []map[string]interface{}
for rows.Next() {
// Scan as raw interface{} to preserve type info ([]byte for BLOBs, int64 for INTs, string for TEXT)
vals := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
fmt.Fprintf(os.Stderr, "scan: %v\n", err)
os.Exit(1)
return nil, nil, err
}
row := make(map[string]interface{})
@ -87,10 +108,8 @@ func main() {
v := vals[i]
switch val := v.(type) {
case []byte:
// Try Unpack (new packed BLOBs)
if unpacked := lib.Unpack(val); unpacked != nil {
s := string(unpacked)
// If it looks like JSON, parse it
if strings.HasPrefix(s, "{") || strings.HasPrefix(s, "[") {
var parsed interface{}
if json.Unmarshal(unpacked, &parsed) == nil {
@ -101,7 +120,6 @@ func main() {
row[col] = s
continue
}
// Try old CryptoDecrypt (legacy base64 strings)
s := string(val)
decrypted := s
for j := 0; j < 10; j++ {
@ -126,7 +144,6 @@ func main() {
case nil:
row[col] = nil
case string:
// Try old CryptoDecrypt for legacy TEXT columns
decrypted := val
for j := 0; j < 10; j++ {
next := lib.CryptoDecrypt(decrypted)
@ -153,23 +170,10 @@ func main() {
}
results = append(results, row)
}
if err := rows.Err(); err != nil {
fmt.Fprintf(os.Stderr, "rows: %v\n", err)
os.Exit(1)
}
_ = colTypes // reserved for future use
switch format {
case "csv":
outputCSV(cols, results)
case "table":
outputTable(cols, results)
default:
out, _ := json.MarshalIndent(results, "", " ")
fmt.Println(string(out))
return nil, nil, err
}
return cols, results, nil
}
func formatValue(val interface{}) string {
@ -242,3 +246,82 @@ func outputTable(cols []string, results []map[string]interface{}) {
fmt.Println()
}
}
const stagingIP = "192.168.1.253"
func isStaging() bool {
addrs, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.String() == stagingIP {
return true
}
}
return false
}
func serveHTTP() {
if !isStaging() {
fmt.Fprintln(os.Stderr, "dbquery -serve: refused (not staging)")
os.Exit(1)
}
if err := lib.CryptoInit(lib.KeyPathDefault); err != nil {
fmt.Fprintf(os.Stderr, "crypto init: %v\n", err)
os.Exit(1)
}
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "db open: %v\n", err)
os.Exit(1)
}
http.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req struct{ SQL string `json:"sql"` }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.SQL == "" {
http.Error(w, `{"error":"sql required"}`, http.StatusBadRequest)
return
}
_, results, err := queryDB(db, req.SQL)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
})
http.HandleFunc("/exec", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req struct{ SQL string `json:"sql"` }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.SQL == "" {
http.Error(w, `{"error":"sql required"}`, http.StatusBadRequest)
return
}
result, err := db.Exec(req.SQL)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
affected, _ := result.RowsAffected()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int64{"affected": affected})
})
log.Printf("dbquery serving on :9124 (staging only)")
log.Fatal(http.ListenAndServe(":9124", nil))
}

207
tools/rquery/main.go Normal file
View File

@ -0,0 +1,207 @@
package main
import (
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"inou/lib"
)
var hosts = map[string]string{
"dev": "johan@192.168.1.253",
"prod": "johan@192.168.100.2",
}
const (
remoteKeyPath = "/tank/inou/master.key"
remoteDBPath = "/tank/inou/data/inou.db"
cacheDir = ".cache/inou"
)
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: rquery <dev|prod> <SQL>")
fmt.Fprintln(os.Stderr, " Runs SQL via SSH, decrypts locally.")
fmt.Fprintln(os.Stderr, " rquery dev \"SELECT * FROM entries LIMIT 5\"")
os.Exit(1)
}
env := os.Args[1]
host, ok := hosts[env]
if !ok {
fmt.Fprintf(os.Stderr, "Unknown env %q (use dev or prod)\n", env)
os.Exit(1)
}
query := strings.Join(os.Args[2:], " ")
if err := initCrypto(env, host); err != nil {
fmt.Fprintf(os.Stderr, "crypto: %v\n", err)
os.Exit(1)
}
// Get column names from LIMIT 1, then re-run with hex() on all columns
colCmd := exec.Command("ssh", host,
fmt.Sprintf(`sqlite3 -json %s "SELECT * FROM (%s) LIMIT 1"`, remoteDBPath, escSQL(query)))
colOut, err := colCmd.Output()
if err != nil {
// Fallback: just run the query as-is
runRaw(host, query)
return
}
var sample []map[string]interface{}
if err := json.Unmarshal(colOut, &sample); err != nil || len(sample) == 0 {
runRaw(host, query)
return
}
// Build hex-wrapped query
var cols []string
for col := range sample[0] {
cols = append(cols, col)
}
var hexCols []string
for _, col := range cols {
hexCols = append(hexCols, fmt.Sprintf(`hex("%s") as "%s"`, col, col))
}
hexQuery := fmt.Sprintf(`SELECT %s FROM (%s)`, strings.Join(hexCols, ", "), query)
cmd := exec.Command("ssh", host,
fmt.Sprintf(`sqlite3 -json %s "%s"`, remoteDBPath, escSQL(hexQuery)))
out, err := cmd.Output()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
fmt.Fprintf(os.Stderr, "%s\n", ee.Stderr)
}
fmt.Fprintf(os.Stderr, "ssh: %v\n", err)
os.Exit(1)
}
var rows []map[string]interface{}
if err := json.Unmarshal(out, &rows); err != nil {
fmt.Print(string(out))
return
}
// Decode hex and decrypt
for _, row := range rows {
for col, v := range row {
row[col] = decodeAndDecrypt(v)
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.Encode(rows)
}
func escSQL(s string) string {
return strings.ReplaceAll(s, `"`, `\"`)
}
func runRaw(host, query string) {
cmd := exec.Command("ssh", host,
fmt.Sprintf(`sqlite3 -json %s "%s"`, remoteDBPath, escSQL(query)))
out, _ := cmd.Output()
fmt.Print(string(out))
}
func initCrypto(env, host string) error {
home, _ := os.UserHomeDir()
dir := filepath.Join(home, cacheDir)
os.MkdirAll(dir, 0700)
keyFile := filepath.Join(dir, env+".key")
if _, err := os.Stat(keyFile); err != nil {
cmd := exec.Command("ssh", host, "cat", remoteKeyPath)
key, err := cmd.Output()
if err != nil {
return fmt.Errorf("fetch key from %s: %w", env, err)
}
if err := os.WriteFile(keyFile, key, 0600); err != nil {
return fmt.Errorf("cache key: %w", err)
}
}
return lib.CryptoInit(keyFile)
}
func decodeAndDecrypt(v interface{}) interface{} {
s, ok := v.(string)
if !ok {
return v
}
// Decode hex
raw, err := hex.DecodeString(s)
if err != nil {
return s // not hex, return as-is
}
// Empty
if len(raw) == 0 {
return nil
}
// Try as integer (sqlite integers are stored as variable-length big-endian in hex)
// Check: if it's short and all the original was digits, it was likely a plain integer
// Actually, hex(123) = "313233" (hex of ASCII "123") for TEXT,
// but hex(123) for INTEGER = "7B". Let's handle both.
// Try Unpack first (packed BLOBs)
if unpacked := lib.Unpack(raw); unpacked != nil {
str := string(unpacked)
if strings.HasPrefix(str, "{") || strings.HasPrefix(str, "[") {
var parsed interface{}
if json.Unmarshal(unpacked, &parsed) == nil {
return parsed
}
}
return str
}
// Try as UTF-8 text, then CryptoDecrypt
str := string(raw)
// Check if it's a plain integer
isNum := len(str) > 0
for _, c := range str {
if c < '0' || c > '9' {
isNum = false
break
}
}
if isNum {
var n json.Number
n = json.Number(str)
if i, err := n.Int64(); err == nil {
return i
}
}
// Try CryptoDecrypt chain
decrypted := str
for i := 0; i < 10; i++ {
next := lib.CryptoDecrypt(decrypted)
if next == "" || next == decrypted {
break
}
decrypted = next
}
if decrypted != str {
if strings.HasPrefix(decrypted, "{") || strings.HasPrefix(decrypted, "[") {
var parsed interface{}
if json.Unmarshal([]byte(decrypted), &parsed) == nil {
return parsed
}
}
return decrypted
}
return str
}