diff --git a/cookbooks/v2/ai-chief-of-staff.mdx b/cookbooks/v2/ai-chief-of-staff.mdx index 80967eda..3de1cffe 100644 --- a/cookbooks/v2/ai-chief-of-staff.mdx +++ b/cookbooks/v2/ai-chief-of-staff.mdx @@ -1,16 +1,13 @@ --- title: AI Chief of Staff -description: “Quick-start guide to building an AI Chief of Staff with HydraDB using the TypeScript SDK. Register workspace functions as knowledge objects and let any agent ask 'What should I do?' to receive a structured, personalized execution plan. For the full production guide with Python, multi-step planning, security, and observability, see the complete AI Chief of Staff cookbook.” -noindex: true +description: “Quick-start guide to building an AI Chief of Staff with HydraDB. Register workspace functions as context items and let any agent ask 'What should I do?' to receive a structured, personalized execution plan. For the full production guide with Python, multi-step planning, security, and observability, see the complete AI Chief of Staff cookbook.” --- -This page is deprecated: it documents the knowledge and memory API, which unified databases replace. See [Ingest context](/essentials/v2/ingest) and [Query](/essentials/v2/query). - ->This page covers the core concepts and TypeScript patterns in 20 minutes. For the full production implementation - Python SDK, multi-step planning, policy engine, approval workflows, observability, and benchmarks - see the [complete AI Chief of Staff cookbook](/cookbooks/hydradb-cookbook-06). +>This page covers the core concepts and TypeScript patterns in 20 minutes. For the full production implementation - Python, multi-step planning, policy engine, approval workflows, observability, and benchmarks - see the [complete AI Chief of Staff cookbook](/cookbooks/v2/hydradb-cookbook-06). This guide walks you through the key building blocks of an **AI Chief of Staff** - an _AI version of n8n_ - powered by HydraDB. Instead of only _answering_ questions, this assistant can **_take actions_** across every app in your workspace by selecting and executing the correct function at the right time. -> **Note**: All code in this guide uses the official HydraDB TypeScript SDK (`@hydradb/sdk`). Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). +> **Note**: All code in this guide calls the HydraDB REST API directly with `fetch`. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). > **Goal**: Let any agent ask HydraDB _”What should I do next?”_ and receive a structured function call (plus parameters) that your execution layer can run. @@ -20,14 +17,13 @@ This guide walks you through the key building blocks of an **AI Chief of Staff** **Required tools**: - HydraDB API key - Node.js 18+ (`node --version`) -- `npm install @hydradb/sdk` ## What You'll Build By the end of this quick start, you'll be able to: -- Register workspace functions (Slack, Calendar, Jira) as HydraDB knowledge objects +- Register workspace functions (Slack, Calendar, Jira) as HydraDB context items - Ask HydraDB “What should I do for this task?” and receive the right function and parameters -- Feed execution results back as memory so HydraDB improves suggestions over time +- Feed execution results back as context so HydraDB improves suggestions over time ## The “Second Brain” Concept @@ -72,7 +68,7 @@ graph LR A["User / Agent"] -->|"ask(task)"| B["Action Orchestrator
• Policy Engine
• Retry / Logging
• Auth Vault
• Function Cache"] B -->|"call(fn)"| C["Workspace Apps
(Slack, Jira …)"] D["HydraDB"] -->|"function suggestions"| B - B -->|"feedback / events / memories"| A + B -->|"feedback / events / results"| A ``` @@ -84,9 +80,9 @@ graph LR ## How HydraDB Essential Features Enable This -### AI Memories for Function Learning +### Context for Function Learning -HydraDB's **AI Memories** don't just remember user preferences - they learn **function effectiveness patterns**. When a user frequently chooses certain functions for specific types of tasks, HydraDB builds a personalized “function preference profile.” This means your AI agent gets smarter suggestions over time without any manual training. +HydraDB doesn't just remember user preferences - it learns **function effectiveness patterns**. When a user frequently chooses certain functions for specific types of tasks, HydraDB builds a personalized “function preference profile.” This means your AI agent gets smarter suggestions over time without any manual training. **Example**: If Sarah always prefers Slack notifications over email for urgent updates, HydraDB learns this pattern and automatically suggests `send_slack_message` instead of `send_email` for her urgent notifications. @@ -120,10 +116,10 @@ This isn't just about security - it's about **cognitive focus**. By limiting fun ### 1.1 Function Schema -HydraDB treats each callable as a **knowledge object**. The minimal schema: +HydraDB treats each callable as a **context item**. The minimal schema your orchestrator works with: -```jsonc +```json { "id": "send_slack_message", "name": "Send a Slack message", @@ -147,27 +143,47 @@ HydraDB treats each callable as a **knowledge object**. The minimal schema: ### 1.2 Upload to HydraDB -Use the `/context/ingest` endpoint with `app_knowledge` to register each function as a knowledge object. +Use the `/context/ingest` endpoint to register each function as a context item. The item's `text` carries the function name, description and parameter schema so HydraDB can reason over it. ```ts -import { HydraDBClient } from "@hydradb/sdk"; - -const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY }); +const BASE_URL = "https://api.hydradb.com"; +const HEADERS = { + Authorization: `Bearer ${process.env.HYDRA_DB_API_KEY}`, + "API-Version": "2", + "Content-Type": "application/json", +}; + +async function ingestContext(body: Record) { + const res = await fetch(`${BASE_URL}/context/ingest`, { + method: "POST", + headers: HEADERS, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`ingest failed: ${res.status}`); + return (await res.json()).data; +} -await client.context.ingest({ +await ingestContext({ database: "your_database", - collection: "your_collection", - appKnowledge: JSON.stringify([ + collection: "functions", + context: [ { - id: "send_slack_message", + context_id: "send_slack_message", title: "Send a Slack message", - type: "slack", - timestamp: new Date().toISOString(), - content: { text: JSON.stringify(schema) }, - additional_metadata: { permissions: ["workspace_admins"], tags: ["automation", "slack"] } - } - ]) + text: [ + "Function: send_slack_message", + "Posts a message to a Slack channel on behalf of the user.", + `Parameters: ${JSON.stringify(schema.parameters)}`, + ].join("\n"), + attributes: { deprecated: false }, + custom_attributes: { + app: "slack", + permissions: ["workspace_admins"], + tags: ["automation", "slack"], + }, + }, + ], }); ``` @@ -176,7 +192,7 @@ await client.context.ingest({ ### 1.3 Versioning & Deprecation -Store new versions with `id: functionName_v2`. Mark old versions' `hydradb_metadata.deprecated = true` so HydraDB avoids suggesting them. +Store new versions with `context_id: functionName_v2`. To retire an old version, re-ingest it with `upsert` and mark it `deprecated` in `attributes` (declare a `deprecated` field of type `BOOL` in `database_metadata_schema`), then exclude it on query with an `attributes` filter. --- @@ -186,49 +202,57 @@ The orchestrator bridges HydraDB ↔ real APIs. ```ts -import { HydraDBClient } from "@hydradb/sdk"; - class Orchestrator { - private client: HydraDBClient; + private database: string; private registry: Map; - constructor(client: HydraDBClient, registry: Map) { - this.client = client; + constructor(database: string, registry: Map) { + this.database = database; this.registry = registry; } - async handleTask(task: string, userContext: { database: string; collection: string }) { + private async post(path: string, body: Record) { + const res = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: HEADERS, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`${path} failed: ${res.status}`); + return (await res.json()).data; + } + + async handleTask(task: string, collection: string) { // 1️⃣ Ask HydraDB which function best matches the task - const result = await this.client.query({ - database: userContext.database, - collection: userContext.collection, + const data = await this.post("/query", { + database: this.database, + collection, query: task, mode: "thinking", - maxResults: 5 + max_results: 5, + attributes: { deprecated: false }, }); - if (!result.data?.chunks || result.data.chunks.length === 0) return { status: "noop" }; + if (!data.chunks || data.chunks.length === 0) return { status: "noop" }; // 2️⃣ Use the top-ranked chunk to identify and execute the function - const topChunk = result.data.chunks[0]; - const functionId = topChunk.id; + const topChunk = data.chunks[0]; + const functionId = topChunk.context_id; const exec = this.registry.get(functionId); if (!exec) return { status: "noop" }; const execResult = await exec(topChunk); - // 3️⃣ Optional: feed result back to HydraDB as a user memory - await this.client.context.ingest({ - type: 'memory', - database: userContext.database, - collection: userContext.collection, - upsert: true, - memories: JSON.stringify([ + // 3️⃣ Optional: feed the result back to HydraDB so future suggestions improve + await this.post("/context/ingest", { + database: this.database, + collection: "execution-log", + context: [ { - id: `exec_${functionId}_${Date.now()}`, + context_id: `exec_${functionId}_${Date.now()}`, + title: `Execution result: ${functionId}`, text: `Executed function "${functionId}" for task: "${task}". Result: ${summarize(execResult)}`, - infer: true - } - ]) + enrich: true, + }, + ], }); return { status: "done", result: execResult }; @@ -239,8 +263,8 @@ class Orchestrator { > **Notable Flags** > -> - `auto_agent_routing`: Lets HydraDB choose between _answering_ vs _acting_. -> - `multi_step_reasoning`: Enables plans like _“create Zoom, then email invite”_. +> - `mode: "thinking"`: Expands the task and reranks, which picks up multi-function plans like _“create Zoom, then email invite”_. +> - `follow_forceful_relations`: In `thinking` mode, also pulls items a hit declared `forceful_relations` to at ingest, returned under `forceful_relations[]`. --- @@ -355,23 +379,38 @@ The **retrieval engine** finds semantically similar past requests and suggests f ### Context-Aware Function Metadata -Use HydraDB's **metadata filtering** to make function suggestions context-aware: +Declare the fields you want to filter on in `database_metadata_schema` when you create the database, then send them as `attributes` on each function item: -```jsonc +```json { - "id": "approve_expense", - "meta": { + "context_id": "approve_expense", + "title": "Approve an expense", + "text": "Approves an expense report on behalf of a manager. ...", + "attributes": { "department": "finance", - "permission_level": "manager", - "cost_threshold": 1000, - "business_hours_only": true + "permission_level": "manager" } } ``` -When a finance manager requests expense approval during business hours, HydraDB automatically considers these constraints in its function selection logic. +Query with an `attributes` filter so only the functions a caller is allowed to run come back: + + +```ts +const data = await this.post("/query", { + database: this.database, + collection: "functions", + query: task, + mode: "thinking", + max_results: 5, + attributes: { department: "finance", deprecated: false }, +}); +``` + + +When a finance manager requests expense approval, the filter narrows candidates to finance functions before ranking. --- @@ -392,29 +431,29 @@ When a finance manager requests expense approval during business hours, HydraDB | Average time-to-completion | Spot slow external APIs | | Rollback frequency | Detect unstable functions | -Auto-tune by feeding metrics back to HydraDB's memory: +Auto-tune by feeding metrics back to HydraDB: ```ts -await client.context.ingest({ - type: 'memory', +await ingestContext({ database: "your_database", - collection: "your_collection", - upsert: true, - memories: JSON.stringify([ + collection: "execution-log", + context: [ { - id: "metrics_calendar_event", + context_id: "metrics_create_calendar_event", + title: "Function health: create_calendar_event", text: 'Function "create_calendar_event" had slow_response signal with p95 of 2500ms.', - infer: true - } - ]) + enrich: true, + upsert: true, + }, + ], }); ``` --- -## The Compound Effect of AI Memories \+ Function Selection +## The Compound Effect of Context \+ Function Selection As your AI Chief of Staff runs more tasks, something powerful happens: **HydraDB builds institutional knowledge** about how work gets done in your organization. @@ -425,7 +464,7 @@ It learns that: - Customer success follows different escalation paths per account tier - Executive requests often have implicit urgency requirements -This knowledge gets encoded in AI Memories and influences future function suggestions. Your AI agent becomes not just capable of executing tasks, but **wise about how to execute them well** in your specific context. +This knowledge gets encoded as context and influences future function suggestions. Your AI agent becomes not just capable of executing tasks, but **wise about how to execute them well** in your specific context. ### Function Composition Patterns @@ -446,16 +485,16 @@ Your AI agent can reference these learned patterns when planning complex workflo - Start **read-only** (analytics) before enabling write. - Use **idempotent** APIs or implement retries with back-off. - Maintain **simulated staging** workspace for testing. -- Leverage **AI Memories** to personalize function selection over time. +- Feed execution context back to personalize function selection over time. - Use **multi-step reasoning** for complex business processes. -- Implement **metadata filtering** for context-aware suggestions. +- Implement **attribute filtering** for context-aware suggestions. - Feed execution results back to HydraDB for **self-improvement**. --- ## Next Steps -1. Pick one app (e.g., Slack) and register 3–5 high-value actions. +1. Pick one app (e.g., Slack) and register 3 to 5 high-value actions. 2. Build a CLI wrapper around the orchestrator for local experiments. 3. Roll out to a friendly internal team, gather feedback, iterate. diff --git a/cookbooks/v2/ai-linkedin-recruiter.mdx b/cookbooks/v2/ai-linkedin-recruiter.mdx index 757a6d54..26ec3e0c 100644 --- a/cookbooks/v2/ai-linkedin-recruiter.mdx +++ b/cookbooks/v2/ai-linkedin-recruiter.mdx @@ -1,14 +1,11 @@ --- title: "AI LinkedIn: People search in Natural Language" description: "Learn how to build an intelligent recruiting platform that understands natural language queries like 'find me someone who has 5+ years of experience in machine learning and has worked at Apple before' using HydraDB's AI search capabilities." -noindex: true --- -This page is deprecated: it documents the knowledge and memory API, which unified databases replace. See [Ingest context](/essentials/v2/ingest) and [Query](/essentials/v2/query). +This guide demonstrates how to build an AI-powered hiring platform that changes how recruiters and hiring managers discover candidates. Instead of traditional keyword searches, your platform will understand natural language queries and provide intelligent candidate matching using HydraDB's AI capabilities. -This guide demonstrates how to build a revolutionary AI-powered hiring platform that transforms how recruiters and hiring managers discover candidates. Instead of traditional keyword searches, your platform will understand natural language queries and provide intelligent candidate matching using HydraDB's advanced AI capabilities. - -> **Note**: All code in this guide uses the official HydraDB TypeScript SDK (`@hydradb/sdk`). Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). +> **Note**: All code in this guide uses the official HydraDB TypeScript SDK (`@hydradb/sdk`) and plain `fetch` calls. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). SDK snippets use the call shapes in the [SDK reference](/api-reference/v2/sdks) and require an SDK release generated from the current API specification. ## Prerequisites **Required knowledge**: TypeScript/JavaScript basics, REST APIs, environment variables @@ -20,10 +17,10 @@ This guide demonstrates how to build a revolutionary AI-powered hiring platform ## What You'll Build By the end of this cookbook, you'll be able to: -- Upload structured candidate profiles into HydraDB with rich metadata (experience, skills, company history, education) +- Upload structured candidate profiles into HydraDB with rich attributes (experience, skills, company history, education) - Search candidates using natural language queries like "Find me someone who has 5+ years of ML experience and worked at Apple" - Rank candidates by fit score and generate personalized interview questions per candidate -- Store recruiter memory so HydraDB personalizes future candidate suggestions based on past successful hires +- Store recruiter context so HydraDB personalizes future candidate suggestions based on past successful hires ## The Problem with Traditional Hiring Platforms @@ -49,11 +46,11 @@ With HydraDB, recruiters can search naturally: graph TD A["Recruiter Interface
• Natural Language Search
• AI Chat Assistant
• Candidate Profiles"] B["AI Search Engine
• Query Understanding
• Candidate Matching
• Ranking & Scoring"] - C["HydraDB APIs
• Full Search
• AI Memories
• Metadata Search"] + C["HydraDB APIs
• Unified Query
• Context Ingest
• Attribute Filters"] D["Candidate Data Sources
• LinkedIn profiles
• Resumes/CVs
• GitHub profiles
• Portfolio sites"] E["Structured Metadata
• Experience years
• Skills & technologies
• Company history
• Education & certifications"] - F["AI Memory Store
• Recruiter preferences
• Search patterns
• Successful hires
• Team requirements"] + F["Recruiter Context Store
• Recruiter preferences
• Search patterns
• Successful hires
• Team requirements"] A <--> B B <--> C @@ -65,26 +62,48 @@ graph TD ## Step 1: Data Ingestion Strategy +### Create the Database + +One database for your recruiting platform, with the fields you want to filter on declared up front: + +```typescript +import { HydraDBClient } from "@hydradb/sdk"; + +const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY }); +const DATABASE = "recruiting-database"; + +await client.databases.create({ + database: DATABASE, + databaseMetadataSchema: [ + { name: "doc_type", data_type: "VARCHAR" }, + { name: "career_level", data_type: "VARCHAR" }, + { name: "job_search_status", data_type: "VARCHAR" }, + ], +}); + +// Database creation is asynchronous - poll until ready before ingesting +while (!(await client.databases.status({ database: DATABASE })).data?.infra?.readyForIngestion) { + await new Promise((resolve) => setTimeout(resolve, 4000)); +} +``` + +Candidates go into role-family collections like `ml_engineering`. Recruiter context goes into per-recruiter collections like `recruiter-`. + ### Understanding Candidate Data Structure -The key to powerful AI search is structuring candidate data correctly. Here's how to organize candidate information for optimal search results: +The key to good AI search is structuring candidate data correctly. Each candidate becomes one context item: the profile narrative goes in `text`, filterable fields go in `attributes`, and descriptive detail goes in `custom_attributes`. #### Core Candidate Profile Structure ```javascript -const candidateProfile = { - // Required fields for HydraDB - id: 'candidate_123456', - database: 'recruiting_database', - collection: 'ml_engineering', +const candidateContextItem = { + context_id: 'candidate_123456', title: 'Senior Machine Learning Engineer - John Smith', - type: 'candidate_profile', // Type app identifier - timestamp: '2024-01-15T10:30:00Z', // Profile last updated + happened_at: '2024-01-15', // Profile last updated (YYYY-MM-DD) // Main content for AI search - content: { - text: `# John Smith - Senior ML Engineer + text: `# John Smith - Senior ML Engineer ## Experience - **Apple Inc.** (3 years) - Senior ML Engineer, Siri Team @@ -107,20 +126,17 @@ const candidateProfile = { 2 years at a startup building recommendation systems. John has a Master's in Computer Science from Stanford and specializes in deep learning, Python, TensorFlow, and distributed systems. He has published 5 papers on neural networks and holds 2 patents - in speech processing.` - }, + in speech processing.`, - // Database-level metadata (searchable/filterable fields defined in database schema) - metadata: { - total_years_experience: 6, - years_at_current_role: 3, + // Filterable attributes (declared in the database schema) + attributes: { + doc_type: 'candidate_profile', career_level: 'senior', - primary_skills: ['machine_learning', 'deep_learning', 'nlp'], job_search_status: 'actively_looking' }, - // Document-specific metadata - additional_metadata: { + // Descriptive detail (stored with the item, not filterable) + custom_attributes: { // Company history companies: [ { @@ -170,15 +186,51 @@ const candidateProfile = { publications_count: 5, patents_count: 2, github_stars: 1250, - conferences_spoken: 3 - }, + conferences_spoken: 3, + + // Profile URL and additional info + url: 'https://linkedin.com/in/johnsmith-ml', + description: 'Senior ML Engineer with Apple experience, specializing in NLP and speech recognition', - // Profile URL and additional info - url: 'https://linkedin.com/in/johnsmith-ml', - description: 'Senior ML Engineer with Apple experience, specializing in NLP and speech recognition' + // Experience numbers kept here because attribute filters are exact-match + total_years_experience: 6, + years_at_current_role: 3, + primary_skills: ['machine_learning', 'deep_learning', 'nlp'] + } }; ``` +Keep a local copy of each profile keyed by `context_id` when you ingest. Query results return `context_id`, `content`, `score` and `enrichment` per chunk, so ranking code looks the profile up locally rather than expecting metadata back from `/query`. + + +```typescript +const BASE_URL = "https://api.hydradb.com"; +const HEADERS = { + Authorization: `Bearer ${process.env.HYDRA_DB_API_KEY}`, + "API-Version": "2", + "Content-Type": "application/json", +}; + +const candidateProfiles = new Map(); + +async function ingestCandidates(items: any[], collection: string) { + items.forEach(i => candidateProfiles.set(i.context_id, i)); + + const res = await fetch(`${BASE_URL}/context/ingest`, { + method: "POST", + headers: HEADERS, + body: JSON.stringify({ + database: "recruiting-database", + collection, + upsert: true, + context: items, + }), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); // 202 Accepted - poll /context/status before querying +} +``` + ### Critical Metadata Fields for Hiring Success @@ -345,16 +397,20 @@ import { HydraDBClient } from "@hydradb/sdk"; class AIRecruitingSearch { constructor() { this.client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY }); - this.database = 'linkedin-recruiter'; + this.database = 'recruiting-database'; + this.collection = 'ml_engineering'; } - async findCandidates(query, recruiterContext = {}) { - const hiringContext = this.buildHiringInstructions(recruiterContext); + async findCandidates(query, options = {}) { + const hiringContext = this.buildHiringInstructions(options); const enrichedQuery = hiringContext ? `${hiringContext}\n\n${query}` : query; const results = await this.client.query({ database: this.database, + collections: options.collections || [this.collection], query: enrichedQuery, + attributes: options.attributes, // exact-match filters, e.g. { job_search_status: "actively_looking" } + acl: options.acl, // caller principals, e.g. ["priya@firm.com"] - enforces stored ACLs // Hiring-specific configurations maxResults: 20, // More candidates for review @@ -368,19 +424,19 @@ class AIRecruitingSearch { return this.enhanceCandidateResults(results, query); } - buildHiringInstructions(recruiterContext) { + buildHiringInstructions(options) { let instructions = `You are helping a recruiter find the best candidates. `; - - if (recruiterContext.role) { - instructions += `They are looking to fill a ${recruiterContext.role} position. `; + + if (options.role) { + instructions += `They are looking to fill a ${options.role} position. `; } - - if (recruiterContext.company) { - instructions += `The role is at ${recruiterContext.company}. `; + + if (options.company) { + instructions += `The role is at ${options.company}. `; } - - if (recruiterContext.teamSize) { - instructions += `The team size is ${recruiterContext.teamSize}. `; + + if (options.teamSize) { + instructions += `The team size is ${options.teamSize}. `; } instructions += `Focus on candidate fit, experience relevance, and potential for success. @@ -394,13 +450,20 @@ class AIRecruitingSearch { enhanceCandidateResults(results, originalQuery) { if (!results.data?.chunks) return results; + // Chunks carry context_id, content, score and enrichment. The structured + // fields live in the local candidateProfiles map populated at ingest time. + const metaFor = (candidate) => { + const profile = candidateProfiles.get(candidate.context_id) || {}; + return { ...(profile.custom_attributes || {}), ...(profile.attributes || {}) }; + }; + // Add hiring-specific analysis to each candidate const enhancedSources = results.data.chunks.map(candidate => ({ ...candidate, - fit_score: this.calculateFitScore(candidate, originalQuery), - strengths: this.extractStrengths(candidate), - potential_concerns: this.extractConcerns(candidate), - interview_questions: this.suggestInterviewQuestions(candidate, originalQuery) + fit_score: this.calculateFitScore(metaFor(candidate), originalQuery), + strengths: this.extractStrengths(metaFor(candidate)), + potential_concerns: this.extractConcerns(metaFor(candidate)), + interview_questions: this.suggestInterviewQuestions(metaFor(candidate), originalQuery) })); // Sort by fit score @@ -408,14 +471,13 @@ class AIRecruitingSearch { return { ...results, - chunks: enhancedSources, - search_insights: this.generateSearchInsights(enhancedSources, originalQuery) + data: { ...results.data, chunks: enhancedSources }, + search_insights: this.generateSearchInsights(enhancedSources.map(c => ({ meta: metaFor(c) })), originalQuery) }; } - extractStrengths(candidate) { + extractStrengths(meta) { const strengths = []; - const meta = candidate.metadata || {}; if (meta.total_years_experience >= 5) strengths.push("Extensive experience"); if (meta.publications_count > 0) strengths.push("Published researcher"); if (meta.career_level === "senior" || meta.career_level === "staff") strengths.push("Senior-level contributor"); @@ -424,25 +486,23 @@ class AIRecruitingSearch { return strengths; } - extractConcerns(candidate) { + extractConcerns(meta) { const concerns = []; - const meta = candidate.metadata || {}; - if (meta.total_years_experience < 3) concerns.push("Limited experience — verify depth of role"); + if (meta.total_years_experience < 3) concerns.push("Limited experience - verify depth of role"); if ((meta.companies || []).every(c => c.company_size === "large_tech")) { - concerns.push("No startup experience — assess adaptability"); + concerns.push("No startup experience - assess adaptability"); } - if (meta.job_search_status === "passively_looking") concerns.push("Not actively searching — expect longer close cycle"); + if (meta.job_search_status === "passively_looking") concerns.push("Not actively searching - expect longer close cycle"); return concerns; } - suggestInterviewQuestions(candidate, query) { + suggestInterviewQuestions(meta, query) { const questions = []; - const meta = candidate.metadata || {}; if ((meta.companies || []).length > 0) { questions.push(`Walk me through your most impactful project at ${meta.companies[0].name}.`); } if (query.toLowerCase().includes("machine learning") || (meta.primary_skills || []).includes("machine_learning")) { - questions.push("Describe a model you shipped end-to-end — from data to production monitoring."); + questions.push("Describe a model you shipped end-to-end, from data to production monitoring."); } if (meta.has_management_experience) { questions.push("Tell me about a time you had to let someone go. How did you handle it?"); @@ -452,9 +512,9 @@ class AIRecruitingSearch { } generateSearchInsights(candidates, query) { - const avgYears = candidates.reduce((sum, c) => sum + (c.metadata?.total_years_experience || 0), 0) / (candidates.length || 1); + const avgYears = candidates.reduce((sum, c) => sum + (c.meta?.total_years_experience || 0), 0) / (candidates.length || 1); const topCompanies = [...new Set( - candidates.flatMap(c => (c.metadata?.companies || []).map(co => co.name)) + candidates.flatMap(c => (c.meta?.companies || []).map(co => co.name)) )].slice(0, 5); return { total_candidates: candidates.length, @@ -464,53 +524,53 @@ class AIRecruitingSearch { }; } - calculateFitScore(candidate, query) { + calculateFitScore(meta, query) { // Simple scoring algorithm - in practice, you'd use more sophisticated ML let score = 0; - + // Experience relevance - if (candidate.metadata?.total_years_experience) { - const years = candidate.metadata.total_years_experience; + if (meta.total_years_experience) { + const years = meta.total_years_experience; if (query.includes('5+') && years >= 5) score += 30; if (query.includes('3+') && years >= 3) score += 25; if (query.includes('senior') && years >= 5) score += 20; } - + // Company match const queryLower = query.toLowerCase(); - if (candidate.metadata?.companies) { - candidate.metadata.companies.forEach(company => { + if (meta.companies) { + meta.companies.forEach(company => { if (queryLower.includes(company.name.toLowerCase())) { score += 25; } }); } - + // Skill relevance (simplified) - if (candidate.metadata?.primary_skills) { - const skills = candidate.metadata.primary_skills; + if (meta.primary_skills) { + const skills = meta.primary_skills; if (queryLower.includes('machine learning') && skills.includes('machine_learning')) { score += 20; } } - + return Math.min(score, 100); // Cap at 100 } } ``` -## Step 3: AI Memories for Personalized Recruiting +## Step 3: Recruiter Context for Personalized Recruiting ### Understanding Recruiter Patterns -AI memories transform recruiting by learning each recruiter's preferences and patterns: +Per-recruiter context transforms recruiting by learning each recruiter's preferences and patterns. Store signals in a `recruiter-` collection as they happen - searches run, candidates shortlisted, offers accepted - and fan out your candidate query across both collections so the recruiter's history ranks results. -#### What AI Memories Capture +#### What Recruiter Context Captures ```javascript -const recruiterMemoryProfile = { +const recruiterContextProfile = { // Search preferences search_patterns: { preferred_experience_levels: ['senior', 'staff'], @@ -553,56 +613,101 @@ const recruiterMemoryProfile = { ### Implementing Personalized Search +Two calls per search: one writes a signal into the recruiter's collection (what they just searched for), one queries across the candidate and recruiter collections together. + ```javascript class PersonalizedRecruitingSearch extends AIRecruitingSearch { - async searchWithPersonalization(query, recruiterId, jobContext = {}) { - // AI memories are automatically managed by HydraDB when we provide user_name - const personalizedContext = { - sessionId: await this.getRecruiterSessionId(recruiterId), - recruiterId: recruiterId, // Enables automatic AI memory management - ...jobContext - }; + recruiterCollection(recruiterId) { + return `recruiter-${recruiterId}`; + } + + // recruiter is the authenticated session principal - never accept it from + // request input, or a caller could read another recruiter's signals. + // Signal items are stamped with an acl so only that recruiter can retrieve them. + async recordSearchSignal(recruiter, query, outcome = "") { + // Ingest one context item per signal. enrich: true lets HydraDB + // extract implicit preferences from the text. + await fetch(`${BASE_URL}/context/ingest`, { + method: "POST", + headers: HEADERS, + body: JSON.stringify({ + database: this.database, + collection: this.recruiterCollection(recruiter.id), + upsert: true, + context: [{ + text: `Recruiter searched: "${query}". ${outcome}`, + user_name: recruiter.id, + enrich: true, + acl: [`user_email:${recruiter.email}`], + }], + }), + }); + } + + async searchWithPersonalization(query, recruiter, options = {}) { + // Query candidates and the recruiter's own history in one call. + // The recruiter collection contributes preference signals that + // re-rank candidates toward this recruiter's successful patterns. + // acl filters stored items to what this recruiter may see. + const results = await this.findCandidates(query, { + ...options, + collections: [this.collection, this.recruiterCollection(recruiter.id)], + acl: [recruiter.email], + }); + + await this.recordSearchSignal(recruiter, query); - const results = await this.findCandidates(query, personalizedContext); - // Add personalized insights based on recruiter's history - return this.addPersonalizedInsights(results, recruiterId); + return this.addPersonalizedInsights(results, recruiter); } - async addPersonalizedInsights(results, recruiterId) { - // Get recruiter's AI memory context (managed automatically by HydraDB) - const recruiterProfile = await this.getRecruiterProfile(recruiterId); - + async addPersonalizedInsights(results, recruiter) { + // Retrieve the recruiter's profile signals + const recruiterProfile = await this.getRecruiterProfile(recruiter); + const personalizedResults = { ...results, personalized_insights: { recommended_candidates: this.getRecommendedCandidates(results.data?.chunks, recruiterProfile), - similar_to_past_hires: this.findSimilarToPastHires(results.data?.chunks, recruiterProfile), - interview_suggestions: this.generatePersonalizedInterviewQuestions(results.data?.chunks, recruiterProfile), - salary_insights: this.generateSalaryInsights(results.data?.chunks, recruiterProfile) + interview_suggestions: this.generatePersonalizedInterviewQuestions(results.data?.chunks, recruiterProfile) } }; return personalizedResults; } + async getRecruiterProfile(recruiter) { + const profile = await this.client.query({ + database: this.database, + collection: this.recruiterCollection(recruiter.id), + acl: [recruiter.email], + query: "preferred skills company types experience levels successful hires", + maxResults: 10, + mode: "thinking", + }); + return profile.data?.chunks || []; + } + getRecommendedCandidates(candidates, recruiterProfile) { + // recruiterProfile is the list of chunks returned from the recruiter's + // collection. Search them for company and skill mentions. + const profileText = recruiterProfile.map(c => c.content || "").join(" ").toLowerCase(); + return candidates .filter(candidate => { - // Check if candidate matches recruiter's successful hire patterns - const companies = candidate.metadata?.companies?.map(c => c.name.toLowerCase()) || []; - const skills = candidate.metadata?.primary_skills || []; - - // Match against successful hire patterns - const hasSuccessfulCompanyBackground = companies.some(company => - recruiterProfile.successful_hires?.common_backgrounds?.includes(company) + const profile = candidateProfiles.get(candidate.context_id) || {}; + const meta = { ...(profile.custom_attributes || {}), ...(profile.attributes || {}) }; + const companies = (meta.companies || []).map(c => c.name.toLowerCase()); + const skills = meta.primary_skills || []; + + const hasSuccessfulCompanyBackground = companies.some(company => + profileText.includes(company) ); - - const hasPreferredSkills = skills.some(skill => - recruiterProfile.search_patterns?.frequently_searched_skills?.includes(skill) + const hasPreferredSkills = skills.some(skill => + profileText.includes(skill.replace(/_/g, " ")) ); - + return hasSuccessfulCompanyBackground || hasPreferredSkills; }) .slice(0, 5); // Top 5 recommendations @@ -610,44 +715,42 @@ class PersonalizedRecruitingSearch extends AIRecruitingSearch { generatePersonalizedInterviewQuestions(candidates, recruiterProfile) { const questions = []; - + candidates.slice(0, 3).forEach(candidate => { + const profile = candidateProfiles.get(candidate.context_id) || {}; + const meta = { ...(profile.custom_attributes || {}), ...(profile.attributes || {}) }; const candidateQuestions = []; - + // Generate questions based on candidate's experience - if (candidate.metadata?.companies) { - candidate.metadata.companies.forEach(company => { + if (meta.companies) { + meta.companies.forEach(company => { candidateQuestions.push( - `Tell me about your experience at ${company.name} and how it relates to our ${recruiterProfile.hiring_context?.team_type} team.` + `Tell me about your experience at ${company.name} and how it relates to our team.` ); }); } - - // Add technical questions based on recruiter's preferences - if (recruiterProfile.hiring_context?.common_interview_topics?.includes('system_design')) { - candidateQuestions.push( - "Can you walk me through how you'd design a system to handle [specific use case relevant to the role]?" - ); - } - + + candidateQuestions.push( + "Can you walk me through how you'd design a system to handle [specific use case relevant to the role]?" + ); + questions.push({ - candidate_name: candidate.title?.split(' - ')[1] || 'Candidate', - candidate_id: candidate.id, + candidate_name: candidate.context_id, suggested_questions: candidateQuestions }); }); - + return questions; } } ``` -### Example: AI Memory in Action +### Example: Recruiter Context in Action -Here's how AI memories make recruiting more effective: +Here's how per-recruiter context makes recruiting more effective: -#### Initial Search (No Memory) +#### Initial Search (No History) ``` @@ -660,7 +763,7 @@ Basic Results: ``` -#### After 10 Searches (AI Memory Active) +#### After 10 Searches (Recruiter Context Active) ``` @@ -673,7 +776,7 @@ AI-Enhanced Results: - Includes salary expectations matching recruiter's budget - Recommends candidates with remote experience (company is remote-first) -AI Memory Insights: +Recruiter Context Insights: "Based on your previous successful hires, I'm highlighting candidates who: - Have experience at high-growth startups like your previous hires from Stripe and Airbnb - Combine React with Node.js and TypeScript (your most successful tech stack combination) @@ -726,40 +829,36 @@ const complexQueries = [ ``` -### Metadata-assisted filtering +### Attribute-assisted filtering -Use HydraDB metadata filters for exact hard requirements (for example `job_search_status: "open"`). Keep range requirements such as years of experience or salary in the natural-language query and in your own ranking step; `metadata_filters` are exact-match constraints, not range operators. +Use HydraDB `attributes` filters for exact hard requirements (for example `job_search_status: "open"`). Keep range requirements such as years of experience or salary in the natural-language query and in your own ranking step; attribute filters are exact-match constraints, not range operators. ```javascript class AdvancedCandidateSearch extends PersonalizedRecruitingSearch { async searchWithComplexCriteria(query, criteria = {}) { const { - experienceRange, - requiredSkills, - preferredCompanies, - salaryRange, - locationRequirements, - educationLevel, availabilityStatus } = criteria; - // Build exact metadata filters for hard equality requirements. - // Do not use metadata_filters for ranges; rank those client-side below. - const metadataFilters = {}; - + // Build exact attribute filters for hard equality requirements. + // Do not use attributes for ranges; rank those client-side below. + const attributeFilters = {}; + if (availabilityStatus) { - metadataFilters.job_search_status = availabilityStatus; + attributeFilters.job_search_status = availabilityStatus; } // Use natural language query for soft requirements and range context. const enhancedQuery = this.enhanceQueryWithContext(query, criteria); const results = await this.findCandidates(enhancedQuery, { - metadataFilters: Object.keys(metadataFilters).length ? metadataFilters : undefined, + attributes: Object.keys(attributeFilters).length ? attributeFilters : undefined, additionalContext: this.buildAdvancedInstructions(criteria) }); + // rankByComplexCriteria re-sorts results by your own multi-criteria + // scoring (see IntelligentCandidateRanking below). return this.rankByComplexCriteria(results, criteria); } @@ -849,14 +948,21 @@ Enhanced Results: ```javascript class IntelligentCandidateRanking { rankCandidates(candidates, searchContext) { + // candidates are chunks from /query plus their local profiles. + // metaFor() merges attributes and custom_attributes keyed by context_id. + const metaFor = (candidate) => { + const profile = candidateProfiles.get(candidate.context_id) || {}; + return { ...(profile.custom_attributes || {}), ...(profile.attributes || {}) }; + }; return candidates.map(candidate => { + const meta = metaFor(candidate); const scores = { - experience_fit: this.scoreExperienceFit(candidate, searchContext), - skill_relevance: this.scoreSkillRelevance(candidate, searchContext), - company_prestige: this.scoreCompanyBackground(candidate, searchContext), - career_trajectory: this.scoreCareerTrajectory(candidate, searchContext), - cultural_fit: this.scoreCulturalFit(candidate, searchContext), - availability: this.scoreAvailability(candidate, searchContext) + experience_fit: this.scoreExperienceFit(meta, searchContext), + skill_relevance: this.scoreSkillRelevance(meta, searchContext), + company_prestige: this.scoreCompanyBackground(meta, searchContext), + career_trajectory: this.scoreCareerTrajectory(meta, searchContext), + cultural_fit: this.scoreCulturalFit(meta, searchContext), + availability: this.scoreAvailability(meta, searchContext) }; const overallScore = this.calculateWeightedScore(scores, searchContext.weights); @@ -870,8 +976,12 @@ class IntelligentCandidateRanking { }).sort((a, b) => b.overall_fit_score - a.overall_fit_score); } - scoreExperienceFit(candidate, context) { - const years = candidate.metadata?.total_years_experience || 0; + // scoreSkillRelevance, scoreCompanyBackground, scoreCareerTrajectory, + // scoreCulturalFit, scoreAvailability and calculateWeightedScore follow + // the same shape as scoreExperienceFit and are omitted for brevity. + + scoreExperienceFit(meta, context) { + const years = meta.total_years_experience || 0; const required = context.experience_requirements || {}; // Perfect match scoring @@ -928,11 +1038,9 @@ AI Understanding: - Implied skills: Frontend, backend, scaling challenges - Implied experience: 5+ years, startup environment -Metadata Filtering: +Attribute Filtering: - career_level: 'senior' -- company_types: ['startup', 'scale_up'] -- technical_domains: ['web_development', 'full_stack'] -- scaling_experience: true +- job_search_status: 'actively_looking' Top Results: 1. Sarah Chen - Senior Full-Stack Engineer at Stripe (Series C) @@ -1029,6 +1137,10 @@ AI search doesn't just find candidates - it helps prepare for better interviews: ```javascript class AIInterviewPrep { + // generateBehavioralQuestions, generateCompanyQuestions, + // generateRedFlagQuestions and identifyKeyAreas follow the same shape + // as the methods shown and are omitted for brevity. + generatePersonalizedQuestions(candidate, role, recruiterHistory) { const questions = { technical_questions: this.generateTechnicalQuestions(candidate, role), @@ -1045,11 +1157,14 @@ class AIInterviewPrep { } generateTechnicalQuestions(candidate, role) { + // candidate is a chunk from /query; look up the stored profile + const profile = candidateProfiles.get(candidate.context_id) || {}; + const meta = { ...(profile.custom_attributes || {}), ...(profile.attributes || {}) }; const questions = []; // Questions based on candidate's specific experience - if (candidate.metadata?.companies) { - candidate.metadata.companies.forEach(company => { + if (meta.companies) { + meta.companies.forEach(company => { if (company.name === 'Apple' && role.includes('ML')) { questions.push( "At Apple, you worked on the Siri team. Can you walk me through how you approached the challenge of improving speech recognition accuracy while maintaining low latency?" @@ -1059,7 +1174,7 @@ class AIInterviewPrep { } // Technology-specific questions - if (candidate.metadata?.technologies?.includes('tensorflow')) { + if (meta.technologies?.includes('tensorflow')) { questions.push( "I see you have extensive TensorFlow experience. How would you design a training pipeline for a model that needs to process real-time data streams?" ); @@ -1069,6 +1184,8 @@ class AIInterviewPrep { } generateInterviewStrategy(candidate, role) { + const profile = candidateProfiles.get(candidate.context_id) || {}; + const meta = { ...(profile.custom_attributes || {}), ...(profile.attributes || {}) }; const strategy = { focus_areas: [], potential_concerns: [], @@ -1077,12 +1194,12 @@ class AIInterviewPrep { }; // Analyze candidate strengths and gaps - if (candidate.metadata?.total_years_experience < role.min_experience) { + if (meta.total_years_experience < role.min_experience) { strategy.potential_concerns.push("Experience level slightly below target - explore depth of experience"); strategy.focus_areas.push("Deep dive into specific projects and impact"); } - if (candidate.metadata?.companies?.some(c => c.company_size === 'large_tech')) { + if (meta.companies?.some(c => c.company_size === 'large_tech')) { strategy.selling_points.push("Highlight startup agility and impact potential"); strategy.follow_up_areas.push("Understand motivation for startup environment"); } @@ -1101,18 +1218,14 @@ class AIInterviewPrep { ```javascript -const recommendedCandidateFields = { - // Core identification (Required by HydraDB) - id: "unique_candidate_identifier", - database: "recruiting_database", - collection: "ml_engineering", +const recommendedCandidateItem = { + // Core identification + context_id: "unique_candidate_identifier", title: "Descriptive candidate title with name and role", - type: "candidate_profile", // Type app identifier - timestamp: "2024-01-01T00:00:00Z", // Profile last updated + happened_at: "2024-01-01", // Profile last updated (YYYY-MM-DD) // Rich content for AI understanding (Critical for good search) - content: { - text: `## Professional Summary + text: `## Professional Summary ## Experience History ## Technical Skills ## Education & Certifications @@ -1126,20 +1239,21 @@ const recommendedCandidateFields = { - Career progression and major transitions - Notable projects and their business impact - Leadership experience and team building - - Industry recognition and community involvement` + - Industry recognition and community involvement`, + + // Filterable attributes (declared in the database schema) + attributes: { + doc_type: "candidate_profile", + career_level: "senior", + job_search_status: "actively_looking" }, - // Database-level metadata (searchable/filterable fields defined in database schema) - metadata: { + // Descriptive detail (stored with the item, not filterable) + custom_attributes: { total_years_experience: 6, years_in_current_role: 2, - career_level: "senior", primary_skills: ["machine_learning", "python", "tensorflow"], - secondary_skills: ["data_engineering", "aws", "kubernetes"] - }, - - // Document-specific metadata - additional_metadata: { + secondary_skills: ["data_engineering", "aws", "kubernetes"], // Company history with context companies: [ { @@ -1205,9 +1319,8 @@ const searchStrategy = { const optimizationTips = { // Batch candidate uploads for efficiency upload_strategy: { - batch_size: 20, // Max 20 candidates per batch - interval_between_batches: 1000, // 1 second between batches - // verify: always call GET /context/status after upload + batch_size: 100, // Up to 100 items per ingest request + // verify: always poll GET /context/status after upload }, // Cache frequent searches @@ -1283,19 +1396,19 @@ const hiringMetrics = { ## Conclusion -Building an AI-powered hiring platform with HydraDB transforms recruiting from a manual, keyword-based process into an intelligent, conversational experience. By leveraging natural language search, rich metadata, and AI memories, recruiters can: +Building an AI-powered hiring platform with HydraDB transforms recruiting from a manual, keyword-based process into an intelligent, conversational experience. By using natural language search, rich attributes, and per-recruiter context, recruiters can: - **Find better candidates faster**: AI understands intent beyond keywords - **Improve matching accuracy**: Semantic search finds relevant candidates traditional systems miss -- **Personalize the experience**: AI memories learn each recruiter's preferences and successful patterns +- **Personalize the experience**: Recruiter context learns each recruiter's preferences and successful patterns - **Scale efficiently**: Handle complex queries that would require multiple traditional searches - **Make data-driven decisions**: Rich insights and scoring help prioritize candidates The key to success lies in: -1. **Rich data ingestion**: Comprehensive candidate profiles with structured metadata +1. **Rich data ingestion**: Comprehensive candidate profiles with structured attributes 2. **Natural language interface**: Let recruiters search as they think and speak -3. **AI memory utilization**: Continuous learning from recruiter behavior and preferences +3. **Recruiter context**: Continuous learning from recruiter behavior and preferences 4. **Iterative refinement**: Improving search quality based on hiring outcomes -Start with core search functionality, gradually add AI memories and personalization, and continuously optimize based on recruiter feedback and hiring success metrics. The result will be a hiring platform that doesn't just find candidates - it understands what makes great hires. +Start with core search functionality, gradually add recruiter context and personalization, and continuously optimize based on recruiter feedback and hiring success metrics. The result will be a hiring platform that doesn't just find candidates - it understands what makes great hires. diff --git a/cookbooks/v2/ai-onboarding-agent.mdx b/cookbooks/v2/ai-onboarding-agent.mdx index 4048cbc2..255af30e 100644 --- a/cookbooks/v2/ai-onboarding-agent.mdx +++ b/cookbooks/v2/ai-onboarding-agent.mdx @@ -1,29 +1,26 @@ --- title: "AI Onboarding Agent" description: "Go from zero to a working onboarding agent in three phases. Upload decision logs, org charts, meeting notes, and product specs into HydraDB. New hires ask 'why did we choose Postgres?' or 'who owns the payments service?' and get answers from real company context - not generic LLM guesses. Every API call in this guide is real and verified." -noindex: true --- -This page is deprecated: it documents the knowledge and memory API, which unified databases replace. See [Ingest context](/essentials/v2/ingest) and [Query](/essentials/v2/query). - This guide walks you through building an **AI onboarding agent with full institutional memory** powered by HydraDB. Unlike a generic chatbot, this agent answers questions from your actual company documents - ADRs, org charts, meeting notes, and product specs. New hires get real context, not hallucinated guesses. -> **Note**: All code in this guide uses the official HydraDB Python SDK. Install it with `pip install hydradb-sdk`. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). +> **Note**: All code in this guide uses the official HydraDB Python SDK (`pip install hydradb-sdk`) with the call shapes in the [SDK reference](/api-reference/v2/sdks); it requires an SDK release generated from the current API specification. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). -> **Goal**: Build an agent that ingests company knowledge, stores per-hire memory, and answers onboarding questions like "why did we choose Postgres?" and "who owns the payments service?" with cited answers from real company documents. +> **Goal**: Build an agent that ingests company context, stores per-hire context, and answers onboarding questions like "why did we choose Postgres?" and "who owns the payments service?" with cited answers from real company documents. --- ## Why Standard Onboarding Fails -The average new hire takes 3–6 months to become fully productive. Most of that lag is not about skill - it is about context. They do not know why the auth service is built the way it is. They do not know who to ask about the data pipeline. They do not know that the pricing model changed in Q3 because of a specific customer situation. +The average new hire takes 3-6 months to become fully productive. Most of that lag is not about skill - it is about context. They do not know why the auth service is built the way it is. They do not know who to ask about the data pipeline. They do not know that the pricing model changed in Q3 because of a specific customer situation. That context exists somewhere - in Confluence pages, Slack threads, decision logs, and the heads of senior engineers - but it is completely inaccessible to someone who just joined. HydraDB fixes this with two capabilities: -1. **Company knowledge graph** - decision logs, org charts, meeting notes, and product specs are ingested into a shared collection. HydraDB links "the auth service" mentioned in a meeting note to the ADR that justified the architecture and the engineer who owns it. A new hire asking "why is auth built this way?" gets all three sources in one answer. -2. **Per-hire memory** - every new hire gets their own memory profile via `collection`. Questions they ask, milestones they complete, and team relationships they build are stored and used to personalize future answers. +1. **Company context graph** - decision logs, org charts, meeting notes, and product specs are ingested into a shared collection. HydraDB links "the auth service" mentioned in a meeting note to the ADR that justified the architecture and the engineer who owns it. A new hire asking "why is auth built this way?" gets all three sources in one answer. +2. **Per-hire context** - every new hire gets their own collection. Questions they ask, milestones they complete, and team relationships they build are stored and used to personalize future answers. --- @@ -34,29 +31,29 @@ graph LR A["Decision logs
Org charts
Meeting notes
Product specs"] -->|"context.ingest()"| B["HydraDB
database: onboarding
collection: company-context"] C["New hire question"] -->|"query()"| B B -->|"retrieved context"| D["Onboarding agent"] - D -->|"context.ingest(type=memory)"| E["HydraDB
collection: hire-{id}"] - E -->|"query(type=memory)"| F["Manager dashboard"] + D -->|"context.ingest()"| E["HydraDB
collection: hire-{id}"] + E -->|"query()"| F["Manager dashboard"] ``` - **Phase 0**: Install SDK, create a database, upload one document, run the first search query. - **Phase 1**: Upload all four knowledge types - ADRs, org chart, product specs, meeting notes. -- **Phase 2**: Store per-hire memory and build the manager dashboard. +- **Phase 2**: Store per-hire context and build the manager dashboard. --- ## What You'll Build By the end of this cookbook, you'll be able to: -- Ingest company ADRs, org charts, meeting notes, and product specs into a shared HydraDB knowledge base +- Ingest company ADRs, org charts, meeting notes, and product specs into a shared HydraDB collection - Verify indexing before running search queries so new hires always get answers from complete data - Answer onboarding questions like "why did we choose Postgres?" and "who owns the payments service?" with cited answers from real documents -- Store per-hire memory so the agent personalizes responses as each hire progresses through onboarding +- Store per-hire context so the agent personalizes responses as each hire progresses through onboarding - Build a manager dashboard that surfaces patterns across all new hires' questions --- ## Phase 0 - Minimal Working System -*10–15 minutes · Goal: upload one document and get a real answer from it* +*10-15 minutes · Goal: upload one document and get a real answer from it* > Do Phase 0 first, even if you plan to skip ahead. Every later phase assumes the database exists and indexing works. @@ -107,7 +104,15 @@ client = HydraDB(token=API_KEY) def create_tenant(): try: - result = client.databases.create(database=DATABASE_ID) + # Declare the metadata fields used to tag documents in Phase 1 + result = client.databases.create( + database=DATABASE_ID, + database_metadata_schema=[ + {"name": "doc_type", "data_type": "VARCHAR"}, + {"name": "team", "data_type": "VARCHAR"}, + {"name": "status", "data_type": "VARCHAR"}, + ], + ) print(f"Database created: {result}") except Exception as e: if "already exists" in str(e).lower() or "limit" in str(e).lower(): @@ -118,9 +123,9 @@ def create_tenant(): # Confirm database is ready status = client.databases.status(database=DATABASE_ID).data print(f"Status: {status.message}") + print(f" ready_for_ingestion={status.infra.ready_for_ingestion}") print(f" scheduler={status.infra.scheduler_status}") print(f" graph={status.infra.graph_status}") - print(f" vectorstore={status.infra.vectorstore_status}") if __name__ == "__main__": create_tenant() @@ -130,22 +135,13 @@ if __name__ == "__main__": python phase0/create_tenant.py ``` -Output: -``` -Database created: {'database': 'onboarding', 'status': 'created'} -Status: Database infrastructure is ready - scheduler=running - graph=running - vectorstore=running -``` - **Expected output:** ``` Database created: ... Status: Deployed infrastructure status + ready_for_ingestion=True scheduler=True graph=True - vectorstore=[True, True] ``` **If it fails:** `403 Plan limit reached` - you've hit your plan's database limit. Reuse an existing `DATABASE_ID`, or upgrade your plan for a higher limit. @@ -185,26 +181,27 @@ from config import API_KEY, DATABASE_ID from hydra_db import HydraDB client = HydraDB(token=API_KEY) +COLLECTION = "company-context" def upload_doc(filepath: str, doc_id: str, doc_type: str, team: str): """ Upload a single text file to the shared company-context collection. - document_metadata must be a JSON string, not a dict. + There is no file upload on a unified database - read the text and + send it as a `text` item in the `context` array. """ - with open(filepath, "rb") as f: - result = client.context.ingest( - database=DATABASE_ID, - documents=[(os.path.basename(filepath), f, "application/octet-stream")], - document_metadata=json.dumps([ - { - "id": doc_id, - "additional_metadata": { - "doc_type": doc_type, - "team": team, - } - } - ]), - ) + with open(filepath, encoding="utf-8") as f: + text = f.read() + + result = client.context.ingest( + database=DATABASE_ID, + collection=COLLECTION, + context=json.dumps([{ + "context_id": doc_id, + "title": os.path.basename(filepath), + "text": text, + "attributes": {"doc_type": doc_type, "team": team}, + }]), + ) print(f"Upload result: {result}") print("Waiting 15 seconds for indexing...") time.sleep(15) @@ -226,7 +223,7 @@ python phase0/upload_doc.py **Expected output:** ``` -Upload result: success=True message='Knowledge uploaded successfully' results=[...] +Upload result: success=True data={'message': 'Context queued for ingestion successfully...', 'results': [...]} Waiting 15 seconds for indexing... Ready to query. ``` @@ -249,6 +246,7 @@ client = HydraDB(token=API_KEY) def retrieve_context(question: str): return client.query( database=DATABASE_ID, + collection="company-context", query=question, max_results=5, mode="thinking", @@ -258,8 +256,8 @@ def retrieve_context(question: str): if __name__ == "__main__": result = retrieve_context("Why did we choose Postgres over MySQL?") for chunk in result.data.chunks: - print(f"\nSource: {chunk.source_title}") - print(chunk.chunk_content[:500]) + print(f"\nSource: {chunk.context_id}") + print(chunk.content[:500]) ``` ```bash @@ -268,7 +266,7 @@ python phase0/query.py **Expected output:** ``` -Source: adr_postgres.txt +Source: adr-001 ADR-001: Why We Chose Postgres Over MySQL Decision: Postgres is our primary database. Rationale: @@ -283,11 +281,11 @@ If no chunks are returned, the document is still indexing. Wait 30 seconds and r --- ## Phase 1 - Ingest All Company Knowledge -*20–30 minutes · Goal: all four knowledge types indexed and answering real questions* +*20-30 minutes · Goal: all four knowledge types indexed and answering real questions* -Four types of institutional knowledge feed the onboarding agent. All go into the shared `company-context` collection. Tag everything with `doc_type` and `team` metadata so new hires can scope questions - "show me engineering decisions" or "what does the product team own?" +Four types of institutional knowledge feed the onboarding agent. All go into the shared `company-context` collection. Tag everything with `doc_type` and `team` attributes (declared in the database schema in Phase 0) so new hires can scope questions - "show me engineering decisions" or "what does the product team own?" -> **Batch limit**: Max 20 files per `context.ingest()` call. For large document sets, upload in batches with a 1-second sleep between them. +> **Batch limit**: Max 100 items per `context.ingest()` call. For large document sets, upload in batches. --- @@ -303,11 +301,12 @@ from config import API_KEY, DATABASE_ID from hydra_db import HydraDB client = HydraDB(token=API_KEY) +COLLECTION = "company-context" def ingest_decision_docs(folder: str): """ Upload all .txt and .md files in a folder as decision docs. - Each file becomes one document in HydraDB. + Each file becomes one context item in HydraDB. """ import pathlib files_to_upload = list(pathlib.Path(folder).glob("*.txt")) + \ @@ -317,30 +316,25 @@ def ingest_decision_docs(folder: str): print(f"No files found in {folder}") return - # Upload in batches of 20 - batch_size = 20 + # Upload in batches of 100 + batch_size = 100 for i in range(0, len(files_to_upload), batch_size): batch = files_to_upload[i:i+batch_size] - file_handles = [open(f, "rb") for f in batch] - metadata = json.dumps([ + items = [ { - "id": f"adr-{f.stem}", - "additional_metadata": {"doc_type": "adr", "team": "engineering"} + "context_id": f"adr-{f.stem}", + "title": f.name, + "text": f.read_text(encoding="utf-8"), + "attributes": {"doc_type": "adr", "team": "engineering"}, } for f in batch - ]) - try: - result = client.context.ingest( - database=DATABASE_ID, - documents=file_handles, - document_metadata=metadata, - ) - print(f" Batch {i//batch_size + 1}: {result.success_count} uploaded") - finally: - for fh in file_handles: - fh.close() - if i + batch_size < len(files_to_upload): - time.sleep(1) + ] + result = client.context.ingest( + database=DATABASE_ID, + collection=COLLECTION, + context=json.dumps(items), + ) + print(f" Batch {i//batch_size + 1}: {result.data.success_count} uploaded") print(f"Waiting 15 seconds for indexing...") time.sleep(15) @@ -358,12 +352,13 @@ Upload a structured people directory - who owns what, who to ask about which sys ```python # phase1/ingest_org.py -import sys, os, json, time, tempfile +import sys, os, json, time sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from config import API_KEY, DATABASE_ID from hydra_db import HydraDB client = HydraDB(token=API_KEY) +COLLECTION = "company-context" def ingest_people(people: list): """ @@ -372,11 +367,9 @@ def ingest_people(people: list): - owns: list of systems/services - areas_of_expertise: list of topics """ - tmp_dir = tempfile.mkdtemp() - file_handles = [] - metadata_list = [] + items = [] - for i, p in enumerate(people): + for p in people: owns_text = "\n".join(f"- {o}" for o in p.get("owns", [])) exp_text = ", ".join(p.get("areas_of_expertise", [])) content = ( @@ -387,26 +380,19 @@ def ingest_people(people: list): f"Expertise: {exp_text}\n\n" f"Owns / responsible for:\n{owns_text}" ) - filepath = os.path.join(tmp_dir, f"person_{i}.txt") - with open(filepath, "w") as f: - f.write(content) - - file_handles.append(open(filepath, "rb")) - metadata_list.append({ - "id": f"person-{p['name'].lower().replace(' ', '-')}", - "additional_metadata": {"doc_type": "person", "team": p["team"]}, + items.append({ + "context_id": f"person-{p['name'].lower().replace(' ', '-')}", + "title": f"{p['name']} - {p['role']}", + "text": content, + "attributes": {"doc_type": "person", "team": p["team"]}, }) - try: - result = client.context.ingest( - database=DATABASE_ID, - documents=file_handles, - document_metadata=json.dumps(metadata_list), - ) - print(f"Org chart: {result.success_count} people uploaded") - finally: - for fh in file_handles: - fh.close() + result = client.context.ingest( + database=DATABASE_ID, + collection=COLLECTION, + context=json.dumps(items), + ) + print(f"Org chart: {result.data.success_count} people uploaded") print("Waiting 15 seconds for indexing...") time.sleep(15) @@ -462,29 +448,22 @@ def ingest_product_docs(folder: str, team: str = "product", status: str = "curre print(f"No files found in {folder}") return - file_handles = [open(f, "rb") for f in files] - metadata = json.dumps([ + items = [ { - "id": f"product-{f.stem}", - "additional_metadata": { - "doc_type": "product_spec", - "team": team, - "status": status, - } + "context_id": f"product-{f.stem}", + "title": f.name, + "text": f.read_text(encoding="utf-8"), + "attributes": {"doc_type": "product_spec", "team": team, "status": status}, } for f in files - ]) + ] - try: - result = client.context.ingest( - database=DATABASE_ID, - documents=file_handles, - document_metadata=metadata, - ) - print(f"Product docs: {result.success_count} uploaded") - finally: - for fh in file_handles: - fh.close() + result = client.context.ingest( + database=DATABASE_ID, + collection="company-context", + context=json.dumps(items), + ) + print(f"Product docs: {result.data.success_count} uploaded") print("Waiting 15 seconds for indexing...") time.sleep(15) @@ -520,28 +499,25 @@ def ingest_meeting_notes(folder: str): print(f"No files found in {folder}") return - file_handles = [open(f, "rb") for f in files] - metadata = json.dumps([ - { - "id": f"meeting-{f.stem}", - "additional_metadata": { - "doc_type": "meeting_notes", - "team": "all", - } + items = [] + for f in files: + item = { + "context_id": f"meeting-{f.stem}", + "title": f.name, + "text": f.read_text(encoding="utf-8"), + "attributes": {"doc_type": "meeting_notes", "team": "all"}, } - for f in files - ]) + # Filename convention YYYY-MM-DD-name: use the date as happened_at + if len(f.stem) >= 10 and f.stem[4] == "-" and f.stem[7] == "-": + item["happened_at"] = f.stem[:10] + items.append(item) - try: - result = client.context.ingest( - database=DATABASE_ID, - documents=file_handles, - document_metadata=metadata, - ) - print(f"Meeting notes: {result.success_count} uploaded") - finally: - for fh in file_handles: - fh.close() + result = client.context.ingest( + database=DATABASE_ID, + collection="company-context", + context=json.dumps(items), + ) + print(f"Meeting notes: {result.data.success_count} uploaded") print("Waiting 15 seconds for indexing...") time.sleep(15) @@ -578,12 +554,13 @@ for question in TEST_QUESTIONS: print(f"\nQ: {question}") result = client.query( database=DATABASE_ID, + collection="company-context", query=question, max_results=5, mode="thinking", graph_context=True, ) - top_chunk = result.data.chunks[0].chunk_content[:300] if result.data.chunks else "No context returned yet" + top_chunk = result.data.chunks[0].content[:300] if result.data.chunks else "No context returned yet" print(f"Context: {top_chunk}") print("-" * 60) ``` @@ -606,14 +583,14 @@ Context: Alice Chen (Senior Engineer) owns the payments pipeline. Her Slack hand --- -## Phase 2 - Per-Hire Memory and Manager Dashboard -*15–20 minutes · Goal: personalized answers per hire and weekly progress reports for managers* +## Phase 2 - Per-Hire Context and Manager Dashboard +*15-20 minutes · Goal: personalized answers per hire and weekly progress reports for managers* --- -### Step 1 - Store New Hire Memory +### Step 1 - Store New Hire Context -Every new hire gets their own memory profile via `collection`. Store their background, milestones, and questions asked. +Every new hire gets their own collection. Store their background, milestones, and questions asked. ```python # phase2/memory.py @@ -630,58 +607,53 @@ def hire_sub(hire_id: str) -> str: """Map hire ID to their HydraDB collection.""" return f"hire-{hire_id.lower()}" +def _ingest_hire_item(hire_id: str, item: dict): + client.context.ingest( + database=DATABASE_ID, + collection=hire_sub(hire_id), + context=json.dumps([item]), + ) + def store_milestone(hire_id: str, milestone: str): """ Record a completed onboarding milestone. - infer=True: HydraDB connects this milestone to related company knowledge - the hire should explore next. + enrich=True (the default): HydraDB connects this milestone to related + company context the hire should explore next. """ ts = datetime.now(timezone.utc).isoformat()[:10] - client.context.ingest( - type='memory', - database=DATABASE_ID, - collection=hire_sub(hire_id), - memories=json.dumps([{ - "text": f"[{ts}] Milestone completed: {milestone}", - "infer": True, - }]), - ) + _ingest_hire_item(hire_id, { + "text": f"[{ts}] Milestone completed: {milestone}", + "enrich": True, + "user_name": hire_id, + }) print(f" Milestone stored for {hire_id}: {milestone}") def log_question(hire_id: str, question: str, topic: str = ""): """ Store a question asked by the hire verbatim. - infer=False: preserve exact question for pattern analysis. + enrich=False: preserve exact question for pattern analysis. 3+ questions on the same topic signal confusion. """ ts = datetime.now(timezone.utc).isoformat()[:10] text = f"[{ts}] Question asked: {question}" if topic: text += f" [topic: {topic}]" - client.context.ingest( - type='memory', - database=DATABASE_ID, - collection=hire_sub(hire_id), - memories=json.dumps([{ - "text": text, - "infer": False, - }]), - ) + _ingest_hire_item(hire_id, { + "text": text, + "enrich": False, + "user_name": hire_id, + }) def store_relationship(hire_id: str, person: str, context: str): """ Store a team relationship for the hire. - infer=True: HydraDB links this person to the systems they own. + enrich=True: HydraDB links this person to the systems they own. """ - client.context.ingest( - type='memory', - database=DATABASE_ID, - collection=hire_sub(hire_id), - memories=json.dumps([{ - "text": f"Team relationship: {person} - {context}", - "infer": True, - }]), - ) + _ingest_hire_item(hire_id, { + "text": f"Team relationship: {person} - {context}", + "enrich": True, + "user_name": hire_id, + }) print(f" Relationship stored: {person}") if __name__ == "__main__": @@ -703,7 +675,7 @@ if __name__ == "__main__": ### Step 2 - Personalized Search -Use the hire's `collection` for personal memory and the default company knowledge scope for source context: +Use `collections` to search the company collection and the hire's own collection in one call: ```python # phase2/ask.py @@ -722,23 +694,19 @@ def ask_onboarding(hire_id: str, question: str, topic: str = "") -> str: """ log_question(hire_id, question, topic) - company_context = client.query( + # One call fans out across the company collection and the hire's own + result = client.query( database=DATABASE_ID, + collections=["company-context", hire_sub(hire_id)], query=question, - max_results=5, + max_results=8, mode="thinking", graph_context=True, ) - hire_context = client.query( - type="memory", - database=DATABASE_ID, - collection=hire_sub(hire_id), - query=question, - max_results=3, - ) return { - "company_context": company_context.data.chunks, - "hire_context": hire_context.data.chunks, + "chunks": result.data.chunks, + "graph": result.data.graph, + "llm_prompt": result.data.llm_prompt, } if __name__ == "__main__": @@ -754,7 +722,7 @@ if __name__ == "__main__": ### Step 3 - Manager Dashboard -Generate a structured weekly progress report for any hire from their stored memory. No forms, no manual updates - data comes directly from questions asked and milestones completed. +Generate a structured weekly progress report for any hire from their stored context. No forms, no manual updates - data comes directly from questions asked and milestones completed. ```python # phase2/dashboard.py @@ -768,7 +736,7 @@ client = HydraDB(token=API_KEY) def generate_progress_report(hire_id: str) -> dict: """ - Generate a structured onboarding progress report from the hire's memory. + Generate a structured onboarding progress report from the hire's collection. No manual input needed - data comes from stored milestones and questions. """ queries = { @@ -781,12 +749,11 @@ def generate_progress_report(hire_id: str) -> dict: report = {} for key, query in queries.items(): result = client.query( - type="memory", database=DATABASE_ID, collection=hire_sub(hire_id), query=query, ) - report[key] = result + report[key] = result.data.llm_prompt print(f" [{key}]: retrieved") return report @@ -806,11 +773,10 @@ if __name__ == "__main__": |---|---|---| | `ModuleNotFoundError: No module named 'hydra_db'` | SDK not installed or wrong package name | Run `pip install hydradb-sdk`. Import as `from hydra_db import HydraDB`. | | `403 Plan limit reached` | You've hit your plan's database limit | Reuse an existing database, or upgrade your plan for a higher limit. | -| `answer` is empty string | Documents still indexing | Wait 15–30 seconds after upload before querying. | +| `answer` is empty string | Documents still indexing | Wait 15-30 seconds after upload before querying. | | `UserWarning: Core Pydantic V1 functionality isn't compatible` | Python 3.14 incompatibility | Use Python 3.11 or 3.12. | -| `422 Unprocessable Entity` on upload | Sending JSON text instead of files | Use `client.context.ingest(documents=[...])` - not raw JSON body. | -| `422 field required: memories` on `context.ingest(type="memory")` | Wrong field structure | Pass `memories=json.dumps([{"text": "...", "infer": True}])`. `memories` is a multipart form field, so it takes a JSON **string**, not a Python list. | -| `chunks: []` returned from query | No documents uploaded yet, or indexing not complete | Run upload script first, wait 15s, then query. | +| `400` naming an unknown field on ingest | Sending a legacy key like `memories`, `documents` or `type` | Send `context` - a JSON **string** containing the item array - and put `text` or `conversation` on each item. | +| `chunks: []` returned from query | No documents uploaded yet, indexing not complete, or wrong `collection` | Run upload script first, wait 15s, and query the same collection you ingested into. | --- @@ -818,12 +784,12 @@ if __name__ == "__main__": | Topic | Note | |---|---| -| Batch size | Max 20 files per `context.ingest()` call. Sleep 1s between batches for large document sets. | -| Indexing delays | Always wait 12–15 seconds after upload before querying. Never rely on upload success alone. | +| Batch size | Max 100 items per `context.ingest()` call. Batch larger document sets. | +| Indexing delays | Always wait 12-15 seconds after upload before querying. Never rely on upload success alone - `202` means queued, not indexed. | | Python version | Use Python 3.11 or 3.12. Python 3.14 shows Pydantic compatibility warnings with the SDK. | -| File formats | Upload `.txt` or `.md` files. Convert PDFs and Notion exports to plain text before ingesting. | -| Collection isolation | Use `collection=f"hire-{hire_id}"` for per-hire memory. Company knowledge lives in the default collection. | -| Free plan | Supports multiple databases. This cookbook keeps everything in one shared database - use different `doc_type` metadata to distinguish content. | +| File formats | Read `.txt` or `.md` files and send their contents as `text` items. Convert PDFs and Notion exports to plain text first - there is no file upload on a unified database. | +| Collection isolation | Use `collection=f"hire-{hire_id}"` for per-hire context. Company knowledge lives in the `company-context` collection. | +| Free plan | Supports multiple databases. This cookbook keeps everything in one shared database - use `doc_type` attributes to distinguish content. | --- @@ -833,36 +799,37 @@ All SDK methods used in this cookbook. | Method | Purpose | |---|---| -| `client.databases.create(database=...)` | Create the onboarding database | -| `client.databases.status(database=...)` | Check database is ready | -| `client.context.ingest(database=..., documents=..., document_metadata=...)` | Upload company knowledge documents | -| `client.context.ingest(type='memory', database=..., collection=..., memories=json.dumps([...]))` | Store per-hire memory | -| `client.query(database=..., query=..., max_results=..., mode=...)` | Retrieve company context for a hire question | -| `client.query(type="memory", database=..., collection=..., query=...)` | Search hire memory for manager dashboard | +| `client.databases.create(database=..., database_metadata_schema=...)` | Create the onboarding database | +| `client.databases.status(database=...)` | Check database is ready (`data.infra.ready_for_ingestion`) | +| `client.context.ingest(database=..., collection=..., context=json.dumps([...]))` | Ingest company context items and per-hire items | +| `client.query(database=..., collection=..., query=..., max_results=..., mode=...)` | Retrieve context for a hire question | +| `client.query(database=..., collections=[...], query=...)` | Fan a query out across company and hire collections | ### client.context.ingest - key parameters | Parameter | Type | Notes | |---|---|---| | `database` | `str` | Required. Your database ID. | -| `documents` | `list[IO[bytes]]` | Required. Open file handles - not file paths, not dicts. | -| `document_metadata` | `str` | Optional. JSON **string** (not dict) with per-document `id` and `additional_metadata`. | +| `collection` | `str` | Optional. Scope inside the database, e.g. `company-context` or `hire-emp-001`. | +| `context` | `str` | Required. JSON **string** containing the item array. Each item has `text` or `conversation`, plus optional `context_id`, `title`, `attributes`, `happened_at`, `user_name` and `enrich`. | ### client.query - key parameters | Parameter | Type | Notes | |---|---|---| | `database` | `str` | Required. | +| `collection` / `collections` | `str` / `list` | Optional. Scope to one collection, or fan out across several. | | `query` | `str` | Required. The hire's question in natural language. | | `max_results` | `int` | Optional. Number of chunks to retrieve. | -| `mode` | `str` | Optional. `"fast"` or `"thinking"`. Use `"thinking"` for complex questions. | -| `graph_context` | `bool` | Optional. Set to `True` to return entity paths and chunk relations when available. | +| `mode` | `str` | Optional. `"fast"`, `"thinking"` or `"auto"`. Use `"thinking"` for complex questions. | +| `graph_context` | `bool` | Optional. Defaults to `True`; returns entity paths under `data.graph`. | +| `attributes` | `dict` | Optional. Filter on declared fields like `doc_type` or `team`. | --- ## Benchmarks -Tested across onboarding evaluations at three companies (50–200 employees, knowledge bases of 200–2,000 documents). +Tested across onboarding evaluations at three companies (50-200 employees, knowledge bases of 200-2,000 documents). | Query type | Standard wiki search | HydraDB onboarding agent | Δ | |---|---|---|---| diff --git a/cookbooks/v2/ai-travel-planner.mdx b/cookbooks/v2/ai-travel-planner.mdx index 1325a559..216ebe15 100644 --- a/cookbooks/v2/ai-travel-planner.mdx +++ b/cookbooks/v2/ai-travel-planner.mdx @@ -1,14 +1,11 @@ --- title: "AI Travel Planner" -description: "Learn how to build an intelligent travel planning platform that understands natural language queries and provides personalized recommendations using HydraDB's AI search and memory capabilities." -noindex: true +description: "Learn how to build an intelligent travel planning platform that understands natural language queries and provides personalized recommendations using HydraDB's search and context capabilities." --- -This page is deprecated: it documents the knowledge and memory API, which unified databases replace. See [Ingest context](/essentials/v2/ingest) and [Query](/essentials/v2/query). +This guide demonstrates how to build an AI-powered travel planning platform that transforms how travelers discover, plan, and book their trips. Instead of traditional keyword searches and manual browsing, your platform will understand natural language queries and provide intelligent, personalized travel recommendations using HydraDB. -This guide demonstrates how to build a revolutionary AI-powered travel planning platform that transforms how travelers discover, plan, and book their trips. Instead of traditional keyword searches and manual browsing, your platform will understand natural language queries and provide intelligent, personalized travel recommendations using HydraDB's advanced AI capabilities. - -> **Note**: All code in this guide uses the official HydraDB TypeScript SDK. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). +> **Note**: All code in this guide calls the HydraDB REST API directly with `fetch`. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). ## Prerequisites @@ -16,14 +13,13 @@ This guide demonstrates how to build a revolutionary AI-powered travel planning **Required tools**: - HydraDB API key - Node.js 18+ (`node --version`) -- `npm install @hydradb/sdk` ## What You'll Build By the end of this cookbook, you'll be able to: - Upload hotel, flight, restaurant, and activity data into HydraDB with correct database scoping -- Answer complex natural language travel queries like "Plan a 5-day romantic trip to Italy for $3000" using `fullRecall` with `mode: "thinking"` -- Store per-user travel preferences and booking history as AI memories for personalized recommendations +- Answer complex natural language travel queries like "Plan a 5-day romantic trip to Italy for $3000" with `mode: "thinking"` +- Store per-user travel preferences and booking history as context for personalized recommendations - Build family, business, and adventure trip planning flows using semantic search ## The Problem with Traditional Travel Planning @@ -52,11 +48,11 @@ With HydraDB, travelers can plan naturally and get personalized recommendations: graph TD A["Travel Planning Interface
• Natural Language Search
• AI Chat Assistant
• Personalized Recommendations"] B["AI Travel Engine
• Query Understanding
• Preference Matching
• Itinerary Generation"] - C["HydraDB APIs
• Retrieval Engine
• AI Memories
• Multi-Step Reasoning"] + C["HydraDB APIs
• Retrieval Engine
• Unified Context
• Multi-Step Reasoning"] D["Travel Data Sources
• Hotel databases
• Flight APIs
• Restaurant reviews
• Activity listings
• Travel guides"] E["Structured Travel Data
• Pricing information
• Availability calendars
• Location metadata
• Amenity details"] - F["AI Memory Store
• User preferences
• Travel history
• Booking patterns
• Personalized insights"] + F["User Context
• User preferences
• Travel history
• Booking patterns
• Personalized insights"] A <--> B B <--> C @@ -68,46 +64,83 @@ graph TD ## Step 1: Travel Data Ingestion Strategy -### 1.1 Hotel and Accommodation Data - -Start by uploading comprehensive hotel and accommodation data using HydraDB's knowledge upload API: +Create the database first. Declare a `category` field in the metadata schema so queries can filter by travel category later: ```javascript -import { HydraDBClient } from "@hydradb/sdk"; +const BASE_URL = "https://api.hydradb.com"; +const HEADERS = { + Authorization: `Bearer ${process.env.HYDRA_DB_API_KEY}`, + "API-Version": "2", + "Content-Type": "application/json", +}; -const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY }); +const createDatabase = async (database) => { + const res = await fetch(`${BASE_URL}/databases`, { + method: "POST", + headers: HEADERS, + body: JSON.stringify({ + database, + database_metadata_schema: [ + { name: "category", data_type: "VARCHAR" }, + ], + }), + }); + if (!res.ok) throw new Error(`create database failed: ${res.status}`); + return res.json(); +}; +const ingestContext = async (body) => { + const res = await fetch(`${BASE_URL}/context/ingest`, { + method: "POST", + headers: HEADERS, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`ingest failed: ${res.status}`); + return (await res.json()).data; +}; + +const hydraQuery = async (body) => { + const res = await fetch(`${BASE_URL}/query`, { + method: "POST", + headers: HEADERS, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`query failed: ${res.status}`); + return (await res.json()).data; +}; +``` + + +### 1.1 Hotel and Accommodation Data + +Start by uploading comprehensive hotel and accommodation data as context items: + + +```javascript const uploadHotelData = async (hotels, database, collection) => { - const hotelSources = hotels.map(hotel => ({ - id: `hotel_${hotel.id}`, - database: database, - collection: collection, + const items = hotels.map(hotel => ({ + context_id: `hotel_${hotel.id}`, title: hotel.name, - type: "accommodation", - description: hotel.description, - timestamp: new Date().toISOString(), - content: { - text: `${hotel.name} - ${hotel.description}. Located in ${hotel.city}, ${hotel.country}. - Amenities: ${hotel.amenities.join(', ')}. - Average rating: ${hotel.rating}/5 from ${hotel.reviewCount} reviews. - Price range: ${hotel.priceRange}. - Room types: ${hotel.roomTypes.join(', ')}.`, - }, - additional_metadata: { + text: `${hotel.name} - ${hotel.description}. Located in ${hotel.city}, ${hotel.country}. + Amenities: ${hotel.amenities.join(', ')}. + Average rating: ${hotel.rating}/5 from ${hotel.reviewCount} reviews. + Price range: ${hotel.priceRange}. + Room types: ${hotel.roomTypes.join(', ')}.`, + attributes: { category: "accommodation" }, + custom_attributes: { location: hotel.coordinates, priceRange: hotel.priceRange, rating: hotel.rating, - amenities: hotel.amenities, propertyType: hotel.propertyType } })); - await client.context.ingest({ - database: database, - collection: collection, - appKnowledge: JSON.stringify(hotelSources), + await ingestContext({ + database, + collection, upsert: true, + context: items, }); }; ``` @@ -120,36 +153,29 @@ Upload flight schedules, routes, and transportation options: ```javascript const uploadFlightData = async (flights, database, collection) => { - const flightSources = flights.map(flight => ({ - id: `flight_${flight.id}`, - database: database, - collection: collection, + const items = flights.map(flight => ({ + context_id: `flight_${flight.id}`, title: `${flight.airline} ${flight.flightNumber}`, - type: "transportation", - description: `Flight from ${flight.origin} to ${flight.destination}`, - timestamp: new Date().toISOString(), - content: { - text: `${flight.airline} flight ${flight.flightNumber} from ${flight.origin} to ${flight.destination}. - Duration: ${flight.duration}. - Aircraft: ${flight.aircraft}. - Departure: ${flight.departureTime}. - Arrival: ${flight.arrivalTime}.`, - }, - additional_metadata: { + text: `${flight.airline} flight ${flight.flightNumber} from ${flight.origin} to ${flight.destination}. + Duration: ${flight.duration}. + Aircraft: ${flight.aircraft}. + Departure: ${flight.departureTime}. + Arrival: ${flight.arrivalTime}.`, + attributes: { category: "transportation" }, + custom_attributes: { origin: flight.origin, destination: flight.destination, airline: flight.airline, - duration: flight.duration, price: flight.price, class: flight.class } })); - await client.context.ingest({ - database: database, - collection: collection, - appKnowledge: JSON.stringify(flightSources), + await ingestContext({ + database, + collection, upsert: true, + context: items, }); }; ``` @@ -162,36 +188,29 @@ Upload restaurant information with cuisine types and reviews: ```javascript const uploadRestaurantData = async (restaurants, database, collection) => { - const restaurantSources = restaurants.map(restaurant => ({ - id: `restaurant_${restaurant.id}`, - database: database, - collection: collection, + const items = restaurants.map(restaurant => ({ + context_id: `restaurant_${restaurant.id}`, title: restaurant.name, - type: "dining", - description: restaurant.description, - timestamp: new Date().toISOString(), - content: { - text: `${restaurant.name} - ${restaurant.description}. - Cuisine: ${restaurant.cuisine}. - Location: ${restaurant.address}. - Price range: ${restaurant.priceRange}. - Rating: ${restaurant.rating}/5. - Specialties: ${restaurant.specialties.join(', ')}.`, - }, - additional_metadata: { + text: `${restaurant.name} - ${restaurant.description}. + Cuisine: ${restaurant.cuisine}. + Location: ${restaurant.address}. + Price range: ${restaurant.priceRange}. + Rating: ${restaurant.rating}/5. + Specialties: ${restaurant.specialties.join(', ')}.`, + attributes: { category: "dining" }, + custom_attributes: { cuisine: restaurant.cuisine, priceRange: restaurant.priceRange, rating: restaurant.rating, - location: restaurant.coordinates, dietaryOptions: restaurant.dietaryOptions } })); - await client.context.ingest({ - database: database, - collection: collection, - appKnowledge: JSON.stringify(restaurantSources), + await ingestContext({ + database, + collection, upsert: true, + context: items, }); }; ``` @@ -204,24 +223,17 @@ Upload tourist attractions, activities, and experiences: ```javascript const uploadActivityData = async (activities, database, collection) => { - const activitySources = activities.map(activity => ({ - id: `activity_${activity.id}`, - database: database, - collection: collection, + const items = activities.map(activity => ({ + context_id: `activity_${activity.id}`, title: activity.name, - type: "activity", - description: activity.description, - timestamp: new Date().toISOString(), - content: { - text: `${activity.name} - ${activity.description}. - Category: ${activity.category}. - Duration: ${activity.duration}. - Difficulty: ${activity.difficulty}. - Best time to visit: ${activity.bestSeason}. - Price: ${activity.price}.`, - }, - additional_metadata: { - category: activity.category, + text: `${activity.name} - ${activity.description}. + Category: ${activity.category}. + Duration: ${activity.duration}. + Difficulty: ${activity.difficulty}. + Best time to visit: ${activity.bestSeason}. + Price: ${activity.price}.`, + attributes: { category: "activity" }, + custom_attributes: { duration: activity.duration, difficulty: activity.difficulty, price: activity.price, @@ -229,11 +241,11 @@ const uploadActivityData = async (activities, database, collection) => { } })); - await client.context.ingest({ - database: database, - collection: collection, - appKnowledge: JSON.stringify(activitySources), + await ingestContext({ + database, + collection, upsert: true, + context: items, }); }; ``` @@ -248,30 +260,26 @@ Create a travel query handler that understands complex travel requests: ```javascript class TravelAssistant { - constructor(client) { - this.client = client; - } - async planTrip(query, userProfile) { // Use HydraDB's thinking mode for complex travel planning - const response = await this.client.query({ - query: query, + const data = await hydraQuery({ + query, database: userProfile.database, collection: userProfile.collection, mode: "thinking", alpha: "auto", - maxResults: 20 + max_results: 20 }); - return this.processResponse(response); + return this.processResponse(data); } - async processResponse(response) { - // Generate structured itinerary from AI response + async processResponse(data) { + // data.graph is a list of { origin, triplets, path_summary } paths return { - chunks: response.data?.chunks, - graphRelations: response.data?.graphContext?.chunkRelations, - queryPaths: response.data?.graphContext?.queryPaths + chunks: data?.chunks, + graphPaths: data?.graph, + llmPrompt: data?.llm_prompt }; } } @@ -280,46 +288,41 @@ class TravelAssistant { ### 2.2 Personalized Recommendation Engine -Implement AI memories to remember user preferences and past travel patterns: +Store per-user context to remember preferences and past travel patterns: ```javascript class PersonalizationEngine { - constructor(client) { - this.client = client; - } - - async generateUserMemory(userInteraction) { - // Generate memories based on user's travel preferences and booking patterns - await client.context.ingest({ - type: 'memory', + async saveUserContext(userInteraction) { + // Store the user's signals in their own collection + await ingestContext({ database: userInteraction.database, - collection: userInteraction.userId, + collection: `user-${userInteraction.userId}`, upsert: true, - memories: JSON.stringify([{ - id: `user_${userInteraction.userId}`, + context: [{ + context_id: `signals_${Date.now()}`, + title: "Travel preference signals", text: `User searched for: ${userInteraction.query}. - They showed interest in: ${userInteraction.clickedItems.join(', ')}. - They booked: ${userInteraction.bookedItems.join(', ')}.`, - infer: true, + They showed interest in: ${userInteraction.clickedItems.join(', ')}. + They booked: ${userInteraction.bookedItems.join(', ')}.`, + enrich: true, user_name: userInteraction.userName - }]) + }] }); } - async getPersonalizedRecommendations(database, collection, destination) { - // Retrieve user memories to provide personalized recommendations - const memories = await this.client.query({ - type: "memory", - database: database, - collection: collection, + async getPersonalizedRecommendations(database, userId, destination) { + // Retrieve the user's context to provide personalized recommendations + const data = await hydraQuery({ + database, + collection: `user-${userId}`, query: `travel preferences for ${destination}`, - maxResults: 10 + max_results: 10 }); - // memories.chunks contains the user's past preference signals ranked by relevance. + // data.chunks contains the user's past preference signals ranked by relevance. // Pass these as context to your LLM to generate personalized recommendations. - return memories.data?.chunks; + return data?.chunks; } } ``` @@ -338,16 +341,16 @@ const searchTravelExperiences = async (query, database, collection, filters = {} ${filters.budget ? `budget ${filters.budget}` : ''} ${filters.travelStyle ? `${filters.travelStyle} travel` : ''}`; - const response = await client.query({ + const data = await hydraQuery({ query: searchQuery, - database: database, - collection: collection, + database, + collection, mode: "fast", alpha: 1.0, - maxResults: 20 + max_results: 20 }); - return response; + return data; }; ``` @@ -364,15 +367,15 @@ const handleTravelQuery = async (query, context) => { // Use mode: "fast" for single-category lookups (hotels only, flights only). const isComplex = /itinerary|plan|trip|vacation|week|days/i.test(query); - const response = await client.query({ + const data = await hydraQuery({ query, database: context.database, collection: context.collection, mode: isComplex ? "thinking" : "fast", - maxResults: isComplex ? 20 : 10 + max_results: isComplex ? 20 : 10 }); - return response.data.chunks; + return data.chunks; }; ``` @@ -386,22 +389,24 @@ const handleTravelQuery = async (query, context) => { ```javascript const planFamilyVacation = async (query, userProfile) => { - const response = await client.query({ - query: query, - database: userProfile.database, - collection: userProfile.collection, - mode: "thinking", - maxResults: 20 - }); - - // Filter chunks by type to build a structured itinerary. - // response.data.chunks are ranked by AI relevance — filter by metadata type to categorize. - const itinerary = { - accommodation: response.data.chunks.filter(c => c.metadata?.type === "accommodation"), - activities: response.data.chunks.filter(c => c.metadata?.type === "activity"), - dining: response.data.chunks.filter(c => c.metadata?.type === "dining"), - transportation: response.data.chunks.filter(c => c.metadata?.type === "transportation") - }; + // Query once per category using the `category` attribute declared at ingest. + const itinerary = {}; + for (const [key, category] of Object.entries({ + accommodation: "accommodation", + activities: "activity", + dining: "dining", + transportation: "transportation" + })) { + const data = await hydraQuery({ + query, + database: userProfile.database, + collection: userProfile.collection, + mode: "thinking", + max_results: 5, + attributes: { category } + }); + itinerary[key] = data.chunks; + } return itinerary; }; @@ -415,16 +420,17 @@ const planFamilyVacation = async (query, userProfile) => { ```javascript const planBusinessTravel = async (query, userProfile) => { - const response = await client.query({ - query: query, + const data = await hydraQuery({ + query, database: userProfile.database, collection: userProfile.collection, mode: "fast", - maxResults: 10 + max_results: 10, + attributes: { category: "accommodation" } }); - // Return ranked chunks directly. Filter by metadata.amenities or metadata.location as needed. - return response.data.chunks; + // Return ranked chunks directly. + return data.chunks; }; ``` @@ -436,16 +442,17 @@ const planBusinessTravel = async (query, userProfile) => { ```javascript const planAdventureTravel = async (query, userProfile) => { - const response = await client.query({ - query: query, + const data = await hydraQuery({ + query, database: userProfile.database, collection: userProfile.collection, mode: "thinking", - maxResults: 15 + max_results: 15, + attributes: { category: "activity" } }); - // Return ranked chunks directly. Sort or filter by metadata.difficulty for hiking-specific results. - return response.data.chunks; + // Return ranked chunks directly. + return data.chunks; }; ``` @@ -459,15 +466,13 @@ const planAdventureTravel = async (query, userProfile) => { const getWeatherBasedRecommendations = async (destination, travelDate, database, collection) => { const weatherQuery = `What activities and attractions are best in ${destination} during ${travelDate} considering weather conditions?`; - const response = await client.query({ + return hydraQuery({ query: weatherQuery, - database: database, - collection: collection, + database, + collection, mode: "fast", - maxResults: 10 + max_results: 10 }); - - return response; }; ``` @@ -479,15 +484,13 @@ const getWeatherBasedRecommendations = async (destination, travelDate, database, const getCulturalEventRecommendations = async (destination, travelDate, database, collection) => { const eventQuery = `What cultural events, festivals, or seasonal experiences are happening in ${destination} during ${travelDate}?`; - const response = await client.query({ + return hydraQuery({ query: eventQuery, - database: database, - collection: collection, + database, + collection, mode: "fast", - maxResults: 10 + max_results: 10 }); - - return response; }; ``` @@ -501,18 +504,18 @@ const getCulturalEventRecommendations = async (destination, travelDate, database const analyzeBookingPatterns = async (database, collection, bookingData) => { const analysisText = `User has booked: ${bookingData.map(b => b.description).join(', ')}. What patterns can we identify about their travel preferences?`; - // Generate memory for future personalization - await client.context.ingest({ - type: 'memory', - database: database, - collection: collection, + // Store the booking summary for future personalization + await ingestContext({ + database, + collection, upsert: true, - memories: JSON.stringify([{ - id: `booking_${database}`, + context: [{ + context_id: `booking_patterns_${bookingData[0].userId}`, + title: "Booking pattern summary", text: analysisText, - infer: true, + enrich: true, user_name: bookingData[0].userName - }]) + }] }); }; ``` @@ -525,15 +528,13 @@ const analyzeBookingPatterns = async (database, collection, bookingData) => { const refineSearch = async (originalQuery, userFeedback, database, collection) => { const refinedQuery = `${originalQuery}. User feedback: ${userFeedback}. Please adjust recommendations accordingly.`; - const response = await client.query({ + return hydraQuery({ query: refinedQuery, - database: database, - collection: collection, + database, + collection, mode: "thinking", - maxResults: 10 + max_results: 10 }); - - return response; }; ``` @@ -547,15 +548,13 @@ const refineSearch = async (originalQuery, userFeedback, database, collection) = const handleMultiLanguageQuery = async (query, language, destination, database, collection) => { const localizedQuery = `${query} (query in ${language} for ${destination})`; - const response = await client.query({ + return hydraQuery({ query: localizedQuery, - database: database, - collection: collection, + database, + collection, mode: "fast", - maxResults: 10 + max_results: 10 }); - - return response; }; ``` @@ -567,17 +566,17 @@ const handleMultiLanguageQuery = async (query, language, destination, database, const monitorPriceChanges = async (travelPlan) => { const priceText = `Monitor price changes for: ${travelPlan.description}. Alert if prices drop by 10% or more.`; - // Set up monitoring using HydraDB memories - await client.context.ingest({ - type: 'memory', + // Store the watch request in the user's collection + await ingestContext({ database: travelPlan.database, - collection: travelPlan.userId, + collection: `user-${travelPlan.userId}`, upsert: true, - memories: JSON.stringify([{ - id: `price_${travelPlan.userId}`, + context: [{ + context_id: `price_watch_${travelPlan.userId}`, + title: "Price watch", text: priceText, - infer: true - }]) + enrich: true + }] }); }; ``` @@ -629,15 +628,13 @@ const monitorPriceChanges = async (travelPlan) => { const checkAvailability = async (hotelId, checkIn, checkOut, database, collection) => { const availabilityQuery = `Check availability for ${hotelId} from ${checkIn} to ${checkOut}`; - const response = await client.query({ + return hydraQuery({ query: availabilityQuery, - database: database, - collection: collection, + database, + collection, mode: "fast", - maxResults: 5 + max_results: 5 }); - - return response; }; ``` @@ -649,28 +646,26 @@ const checkAvailability = async (hotelId, checkIn, checkOut, database, collectio const getDynamicPricing = async (searchResults, userProfile) => { const pricingQuery = `Get current pricing for these travel options considering user's booking history and preferences`; - const response = await client.query({ + return hydraQuery({ query: pricingQuery, database: userProfile.database, collection: userProfile.collection, mode: "fast", - maxResults: 10 + max_results: 10 }); - - return response; }; ``` ## Conclusion -Building an AI travel planner with HydraDB transforms the travel planning experience from a tedious research process into an intelligent, conversational journey. By leveraging HydraDB's AI memories, multi-step reasoning, and semantic search capabilities, you can create a platform that truly understands traveler intent and provides personalized recommendations. +Building an AI travel planner with HydraDB transforms the travel planning experience from a tedious research process into an intelligent, conversational journey. With HydraDB's unified context, multi-step reasoning, and semantic search, you can create a platform that truly understands traveler intent and provides personalized recommendations. Key benefits of this approach: - **Natural Language Understanding**: Users can express complex travel desires in natural language -- **Personalized Recommendations**: AI memories ensure recommendations improve over time +- **Personalized Recommendations**: Stored user context ensures recommendations improve over time - **Contextual Awareness**: Multi-step reasoning considers all aspects of travel planning -- **Seamless Integration**: Easy integration with existing booking systems and travel APIs +- **Simple Integration**: Easy integration with existing booking systems and travel APIs The result is a travel planning platform that feels more like having a knowledgeable travel advisor than using a search engine, creating higher user satisfaction and better conversion rates for travel businesses. diff --git a/cookbooks/v2/competitive-intelligence-agent.mdx b/cookbooks/v2/competitive-intelligence-agent.mdx index 4ff97228..78706907 100644 --- a/cookbooks/v2/competitive-intelligence-agent.mdx +++ b/cookbooks/v2/competitive-intelligence-agent.mdx @@ -1,14 +1,11 @@ --- title: "AI Competitive Intelligence Agent" description: "Continuously ingest competitor press releases, job postings, customer reviews, and earnings transcripts into HydraDB. Answer 'What is Competitor X doing right now?' and 'How has their messaging shifted over the last 6 months?' with full temporal context. Every API call in this guide is real and verified." -noindex: true --- -This page is deprecated: it documents the knowledge and memory API, which unified databases replace. See [Ingest context](/essentials/v2/ingest) and [Query](/essentials/v2/query). +This guide walks you through building a **competitive intelligence agent with persistent context** powered by HydraDB. Unlike a static market research doc or a naive RAG pipeline, this agent continuously ingests competitor signals and answers both point-in-time questions ("What has Acme Corp announced about enterprise?") and trend questions ("How has their pricing messaging shifted since Q1?") - with full context across press releases, job postings, customer reviews, and earnings calls unified in one retrieval layer. -This guide walks you through building a **competitive intelligence agent with persistent temporal memory** powered by HydraDB. Unlike a static market research doc or a naive RAG pipeline, this agent continuously ingests competitor signals and answers both point-in-time questions ("What has Acme Corp announced about enterprise?") and trend questions ("How has their pricing messaging shifted since Q1?") - with full context across press releases, job postings, customer reviews, and earnings calls unified in one retrieval layer. - -> **Note**: All code in this guide is production-ready and uses real HydraDB endpoints. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). +> **Note**: All code in this guide is production-ready and uses real HydraDB endpoints. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). SDK snippets use the call shapes in the [SDK reference](/api-reference/v2/sdks) and require an SDK release generated from the current API specification; the cURL examples work today. > **Goal**: Build an agent that ingests four signal types from competitor sources, verifies indexing, and answers competitive queries using `POST /query` with `recency_bias` tuned per query type - point-in-time or trend. Full round-trip under 200ms. @@ -52,13 +49,13 @@ HydraDB fixes this with three capabilities that standard vector search can't rep ```mermaid graph LR A["Signal Sources
RSS · LinkedIn · G2 · Earnings PDFs"] -->|"raw text"| B["Ingestion Layer
connectors/press.py
connectors/jobs.py
connectors/reviews.py
connectors/earnings.py"] - B -->|"POST /context/ingest
multipart form-data"| C["HydraDB
database: competitive-intel
collection: acme-corp"] + B -->|"POST /context/ingest
context items"| C["HydraDB
database: competitive-intel
collection: acme-corp"] D["Analyst / Agent"] -->|"POST /query
query + recency_bias"| C - C -->|"ranked chunks + graph_context"| D + C -->|"chunks + graph + llm_prompt"| D D -->|"weekly briefing"| E["Slack #sales-intel
Slack #product-intel"] ``` -- **Signal Sources**: Press releases via RSS, job postings from LinkedIn/Greenhouse, customer reviews from G2/Capterra, earnings transcripts as plain text or PDF. +- **Signal Sources**: Press releases via RSS, job postings from LinkedIn/Greenhouse, customer reviews from G2/Capterra, earnings transcripts as plain text (extract PDF text in the connector). - **Ingestion Layer**: Connector scripts that format content and upload to HydraDB via `POST /context/ingest`. - **HydraDB**: Stores all signals with timestamps, builds a context graph, and ranks results by recency at query time. - **Analyst / Agent**: A human analyst, a Slack bot, or an LLM that calls `POST /query` with a natural language query. @@ -72,19 +69,24 @@ One database for all competitive intelligence. Collections isolate by competitor ```bash curl -X POST 'https://api.hydradb.com/databases' \ -H "Authorization: Bearer YOUR_API_KEY" \ + -H "API-Version: 2" \ -H "Content-Type: application/json" \ -d '{"database": "competitive-intel"}' ``` ```python # setup.py -import os +import os, time from hydra_db import HydraDB client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) DATABASE_ID = "competitive-intel" client.databases.create(database=DATABASE_ID) + +# Database creation is asynchronous - poll until ready before ingesting +while not client.databases.status(database=DATABASE_ID).data.infra.ready_for_ingestion: + time.sleep(4) print(f"✓ Database '{DATABASE_ID}' ready.") def collection_for(competitor: str) -> str: @@ -105,32 +107,35 @@ Output: ## Step 2 - Ingest Competitor Signals -All four signal types use `POST /context/ingest`. This endpoint uses **multipart form-data**, not JSON. `database` and `collection` are form fields, not body keys. - -> **Important**: Do not set `Content-Type: application/json` on ingestion requests. The endpoint expects `multipart/form-data`. Let your HTTP client set the boundary automatically - only pass `Authorization` in headers. +All four signal types use `POST /context/ingest` with a JSON body: `database`, `collection`, and a `context` array of items. Each item carries `text` (or a `conversation`), plus optional `context_id`, `title`, `happened_at` and attributes. There is no file upload on a unified database - a connector that starts from a PDF extracts the text itself (for example with `pypdf`) and sends it as `text` items. -> **Batch limit**: Max 20 sources per request. Wait 1 second between batches. Always call `GET /context/status` before running search - queries against unindexed sources return empty results. +> **Batch limit**: Max 100 items per request. Always call `GET /context/status` before running search - queries against unindexed items return empty results. -The upload response looks like this for all signal types: +The `202 Accepted` response looks like this for all signal types: ```json { "success": true, - "message": "Knowledge uploaded successfully", - "results": [ - { - "id": "d25fb5a6-0378-4bcb-8cbc-2012c3d12ca2", - "filename": "press-acme-corp-1234567890.txt", - "status": "queued", - "error": null - } - ], - "success_count": 1, - "failed_count": 0 + "data": { + "success": true, + "message": "Context queued for ingestion successfully. ...", + "results": [ + { + "id": "press-acme-corp-1234567890", + "title": "Acme Corp press release", + "status": "queued", + "infer": true, + "error": null, + "error_code": null + } + ], + "success_count": 1, + "failed_count": 0 + } } ``` -Save the `id` from `results[0].id` - you need it to verify indexing. +`202` means queued, not indexed. Save the `id` from `results[0].id` - you need it to verify indexing. ### 2.1 Press Releases & Blog Posts @@ -138,7 +143,7 @@ Press releases are the most explicit signal. Prepend signal metadata to the cont ```python # connectors/press.py -import os, time +import json, os, time from hydra_db import HydraDB client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) @@ -151,18 +156,22 @@ def ingest_press_release(competitor: str, title: str, text: str) -> str: competitor: normalised name, e.g. "acme-corp" title: article headline text: full article body - Returns: id for verification + Returns: context id for verification """ - content = f"Signal type: press_release\nCompetitor: {competitor}\nTitle: {title}\n\n{text}" - filename = f"press-{competitor}-{int(time.time())}.txt" + content = f"Signal type: press_release\nCompetitor: {competitor}\nTitle: {title}\n\n{text}" + context_id = f"press-{competitor}-{int(time.time())}" result = client.context.ingest( database=DATABASE_ID, collection=competitor, - documents=[(filename, content.encode("utf-8"), "text/plain")], + context=json.dumps([{ + "context_id": context_id, + "title": title, + "text": content, + }]), ) id = result.data.results[0].id - print(f"[press] Uploaded {filename} → id: {id}") + print(f"[press] Uploaded {context_id} → id: {id}") return id ``` @@ -172,7 +181,7 @@ Job postings are one of the strongest competitive signals - they reveal exactly ```python # connectors/jobs.py -import os, time +import json, os, time from hydra_db import HydraDB client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) @@ -195,15 +204,19 @@ def ingest_job_posting(competitor: str, title: str, department: str, description f"Department: {department}\n\n" f"{description}" ) - filename = f"job-{competitor}-{int(time.time())}.txt" + context_id = f"job-{competitor}-{int(time.time())}" result = client.context.ingest( database=DATABASE_ID, collection=competitor, - documents=[(filename, content.encode("utf-8"), "text/plain")], + context=json.dumps([{ + "context_id": context_id, + "title": f"{title} ({department})", + "text": content, + }]), ) id = result.data.results[0].id - print(f"[jobs] Uploaded {filename} → id: {id}") + print(f"[jobs] Uploaded {context_id} → id: {id}") return id ``` @@ -213,7 +226,7 @@ Customer reviews are the most honest signal - they surface real objections, real ```python # connectors/reviews.py -import os, time +import json, os, time from hydra_db import HydraDB client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) @@ -232,7 +245,7 @@ def ingest_review( competitor: e.g. "acme-corp" title: review headline body: full review text - rating: 1–5 stars + rating: 1-5 stars reviewer_role: e.g. "IT Director", "VP Engineering" Returns: id for verification """ @@ -245,15 +258,19 @@ def ingest_review( f"Review title: {title}\n\n" f"{body}" ) - filename = f"review-{competitor}-{int(time.time())}.txt" + context_id = f"review-{competitor}-{int(time.time())}" result = client.context.ingest( database=DATABASE_ID, collection=competitor, - documents=[(filename, content.encode("utf-8"), "text/plain")], + context=json.dumps([{ + "context_id": context_id, + "title": title, + "text": content, + }]), ) id = result.data.results[0].id - print(f"[reviews] Uploaded {filename} ({sentiment}) → id: {id}") + print(f"[reviews] Uploaded {context_id} ({sentiment}) → id: {id}") return id ``` @@ -263,7 +280,7 @@ For public competitors, earnings calls contain the most explicit strategic signa ```python # connectors/earnings.py -import os, time +import json, os, time from hydra_db import HydraDB client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) @@ -284,15 +301,19 @@ def ingest_earnings_transcript(competitor: str, quarter: str, transcript: str) - f"Quarter: {quarter}\n\n" f"{transcript}" ) - filename = f"earnings-{competitor}-{quarter}-{int(time.time())}.txt" + context_id = f"earnings-{competitor}-{quarter}-{int(time.time())}" result = client.context.ingest( database=DATABASE_ID, collection=competitor, - documents=[(filename, content.encode("utf-8"), "text/plain")], + context=json.dumps([{ + "context_id": context_id, + "title": f"{competitor} earnings call {quarter}", + "text": content, + }]), ) id = result.data.results[0].id - print(f"[earnings] Uploaded {filename} → id: {id}") + print(f"[earnings] Uploaded {context_id} → id: {id}") return id ``` @@ -300,7 +321,7 @@ def ingest_earnings_transcript(competitor: str, quarter: str, transcript: str) - ## Step 3 - Verify Indexing -After uploading, poll `GET /context/status` until `indexing_status` is `completed`. HydraDB indexes asynchronously - typically 10–30 seconds per file. Do not query until indexing is complete; unindexed sources return empty results. +After uploading, poll `GET /context/status` until `indexing_status` is `completed`. HydraDB indexes asynchronously - typically 10-30 seconds per item. Do not query until indexing is complete; unindexed items return empty results. > **Note**: [`GET /context/status`](/api-reference/v2/endpoint/source-status) takes `ids` and `database` as **query parameters**. Pass multiple `ids` to check a batch in one call. @@ -308,23 +329,28 @@ After uploading, poll `GET /context/status` until `indexing_status` is `complete curl -G \ 'https://api.hydradb.com/context/status' \ -H "Authorization: Bearer YOUR_API_KEY" \ + -H "API-Version: 2" \ --data-urlencode "database=competitive-intel" \ + --data-urlencode "collection=acme-corp" \ --data-urlencode "ids=YOUR_ID" ``` **Response when indexed**: ```json { - "statuses": [ - { - "id": "d25fb5a6-0378-4bcb-8cbc-2012c3d12ca2", - "indexing_status": "completed", - "error_code": "", - "error_message": "", - "success": true, - "message": "Processing status retrieved successfully" - } - ] + "success": true, + "data": { + "statuses": [ + { + "id": "press-acme-corp-1234567890", + "indexing_status": "completed", + "error_code": "", + "error_message": "", + "success": true, + "message": "Processing status retrieved successfully" + } + ] + } } ``` @@ -337,15 +363,17 @@ client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) DATABASE_ID = "competitive-intel" -def wait_until_indexed(id: str, max_tries: int = 20, interval: int = 3) -> None: +def wait_until_indexed(id: str, collection: str, max_tries: int = 20, interval: int = 3) -> None: """ Poll /context/status until the item is indexed or times out. + Pass the same collection you ingested into. Raises RuntimeError if indexing errors. Warns on timeout (may still complete). """ for i in range(max_tries): time.sleep(interval) status_result = client.context.status( database=DATABASE_ID, + collection=collection, ids=[id], ) items = status_result.data.statuses or [] @@ -411,7 +439,7 @@ def ask_about_competitor( """ Query HydraDB for competitor signals. recency_bias: 0.8 = point-in-time, 0.3 = trend - Returns the full API response with chunks, sources, and graph_context. + Returns the full API response: chunks, graph, forceful_relations, llm_prompt. """ return client.query( database=DATABASE_ID, @@ -426,14 +454,13 @@ def ask_about_competitor( def print_results(result) -> None: - """Pretty-print chunks and graph entities from a /query response.""" + """Pretty-print chunks from a /query response.""" chunks = result.data.chunks or [] print(f"\n{len(chunks)} chunks retrieved:\n") for chunk in chunks: - fname = (chunk.additional_metadata or {}).get("filename", "unknown") - score = chunk.relevancy_score or 0 - print(f" [{fname} - {score:.2f}]") - print(f" {(chunk.chunk_content or '')[:200]}...") + score = chunk.score or 0 + print(f" [{chunk.context_id} - {score:.2f}]") + print(f" {(chunk.content or '')[:200]}...") print() @@ -446,45 +473,45 @@ result = ask_about_competitor( print_results(result) ``` -**Response structure**: +**Response structure** (`data` has exactly four keys): ```json { - "chunks": [ - { - "chunk_uuid": "d25fb5a6-..._chunk_0", - "id": "d25fb5a6-...", - "chunk_content": "Signal type: press_release\nCompetitor: acme-corp\n\nAcme Corp today announced AcmeShield...", - "relevancy_score": 0.818, - "additional_metadata": { - "filename": "press-acme-corp-1234567890.txt", - "collection": "acme-corp" + "success": true, + "data": { + "chunks": [ + { + "chunk_id": "press-acme-corp-..._chunk_0", + "context_id": "press-acme-corp-1234567890", + "score": 0.818, + "content": "Signal type: press_release\nCompetitor: acme-corp\n\nAcme Corp today announced AcmeShield...", + "enrichment": "Acme Corp announced the AcmeShield enterprise security product." } - } - ], - "sources": [...], - "graph_context": { - "chunk_relations": [ + ], + "graph": [ { + "origin": "query", "triplets": [ { - "source": {"name": "acmeshield", "type": "PRODUCT"}, - "relation": {"raw_predicate": "includes", "context": "AcmeShield includes SOC 2 Type II compliance..."}, - "target": {"name": "soc 2 type ii", "type": "CONCEPT"} + "source": {"name": "acmeshield", "entity_id": "ent_acmeshield"}, + "relation": {"predicate": "includes", "context": "AcmeShield includes SOC 2 Type II compliance...", "relationship_id": "rel_1", "chunk_id": "press-acme-corp-..._chunk_0"}, + "target": {"name": "soc 2 type ii", "entity_id": "ent_soc2"} }, { - "source": {"name": "acme corp", "type": "ORGANIZATION"}, - "relation": {"raw_predicate": "announced", "context": "Acme Corp announced AcmeShield..."}, - "target": {"name": "acmeshield", "type": "PRODUCT"} + "source": {"name": "acme corp", "entity_id": "ent_acme"}, + "relation": {"predicate": "announced", "context": "Acme Corp announced AcmeShield...", "relationship_id": "rel_2", "chunk_id": "press-acme-corp-..._chunk_0"}, + "target": {"name": "acmeshield", "entity_id": "ent_acmeshield"} } ], - "relevancy_score": 0.534 + "path_summary": "Acme Corp announced AcmeShield, which includes SOC 2 Type II compliance." } - ] + ], + "forceful_relations": [], + "llm_prompt": "# Query results\n\n## Results\n[1] Signal type: press_release ..." } } ``` -> **Reading graph_context**: The `chunk_relations` array shows entities HydraDB automatically extracted and linked across all your uploaded sources. A press release mentioning "AcmeShield" is connected to a job posting mentioning "SAML/SSO" and a G2 review mentioning "enterprise onboarding" - no manual tagging required. This is what surfaces all three when you ask about "enterprise strategy". +> **Reading `graph`**: Each entry is a path of `triplets` - entities HydraDB automatically extracted and linked across all your uploaded items - plus a `path_summary`. A press release mentioning "AcmeShield" is connected to a job posting mentioning "SAML/SSO" and a G2 review mentioning "enterprise onboarding" - no manual tagging required. This is what surfaces all three when you ask about "enterprise strategy". --- @@ -526,8 +553,8 @@ print_results(result2) ``` > **recency_bias guide**: -> - `0.8–1.0` - Point-in-time: "What is X doing now?" Strongly weights the latest signals. -> - `0.3–0.5` - Trend: "How has X changed?" Surfaces old and new for comparison. +> - `0.8-1.0` - Point-in-time: "What is X doing now?" Strongly weights the latest signals. +> - `0.3-0.5` - Trend: "How has X changed?" Surfaces old and new for comparison. > - `0.0` - No bias: equal weight across all time periods. > **Multi-competitor comparison**: To compare two competitors in one query, omit `collection` entirely and ask "How does acme-corp's enterprise positioning compare to betacorp's?" HydraDB searches across all collections within the database and surfaces signals from both. @@ -595,7 +622,8 @@ def recall_for_question(question: str, competitor: str, recency_bias: float) -> chunks = results.data.chunks or [] if not chunks: return "No signals found for this question." - return "\n".join((c.chunk_content or "")[:300] for c in chunks[:3]) + # llm_prompt is the rendered context block; fall back to raw chunk content. + return results.data.llm_prompt or "\n".join((c.content or "")[:300] for c in chunks[:3]) def generate_briefing(analyst: str) -> str: @@ -642,7 +670,7 @@ All endpoints used in this cookbook. Base URL: `https://api.hydradb.com` · Head | Method | Endpoint | Purpose | |--------|----------|---------| | `POST` | `/databases` | Create the competitive-intel database | -| `POST` | `/context/ingest` | Upload a signal file (multipart form-data) | +| `POST` | `/context/ingest` | Upload signal items (JSON `context` array) | | `GET` | `/context/status?database=...&ids=...` | Check indexing status | | `POST` | `/query` | Query indexed signals | @@ -651,19 +679,25 @@ All endpoints used in this cookbook. Base URL: `https://api.hydradb.com` · Head { "database": "competitive-intel" } ``` -### Upload Knowledge (form-data) +### Ingest Context (JSON) -> Do not use `Content-Type: application/json`. This is a multipart upload. - -| Form field | Type | Value | -|---|---|---| -| `database` | Text | `competitive-intel` | -| `collection` | Text | `acme-corp` | -| `documents` | File | your `.txt` or `.pdf` file | +```json +{ + "database": "competitive-intel", + "collection": "acme-corp", + "context": [ + { + "context_id": "press-acme-corp-1234567890", + "title": "Acme Corp press release", + "text": "Signal type: press_release\nCompetitor: acme-corp\n\n..." + } + ] +} +``` ### Verify Processing (query params) ``` -GET /context/status?database=competitive-intel&ids=YOUR_ID +GET /context/status?database=competitive-intel&collection=acme-corp&ids=YOUR_ID ``` ### Full Search - Point-in-Time @@ -702,9 +736,9 @@ Tested across 3 competitor corpora (150+ sources each: press releases, job posti | Metric | Manual / Naive RAG | HydraDB CI Agent | Delta | |--------|-------------------|------------------|-------| -| Time to answer "what is X doing now?" | 30–60 min (manual) | under 10 seconds | **200x faster** | +| Time to answer "what is X doing now?" | 30-60 min (manual) | under 10 seconds | **200x faster** | | Search accuracy on temporal questions | 28% | 81% | **+189%** | -| Stale signals surfaced in top results | 39% | 6% | **−85%** | +| Stale signals surfaced in top results | 39% | 6% | **-85%** | | Signal sources covered | Press only (typically) | All 4 unified | **4x coverage** | | P95 query latency | N/A (manual) | under 200 ms | **Sub-second** | diff --git a/cookbooks/v2/cookbook-01-build-cursor-for-docs.mdx b/cookbooks/v2/cookbook-01-build-cursor-for-docs.mdx index 4b742ed4..c86cf300 100644 --- a/cookbooks/v2/cookbook-01-build-cursor-for-docs.mdx +++ b/cookbooks/v2/cookbook-01-build-cursor-for-docs.mdx @@ -1,16 +1,13 @@ --- title: "Cursor for Docs" description: "Go from zero to a production AI assistant that answers 'why was this built this way?' - in four phases. Start with one file and a real search query. End with a FastAPI backend that ingests GitHub, PRs, Slack, and RFCs, then generates GPT-4o answers grounded in your codebase. Every endpoint in this guide is real and copy-paste ready." -noindex: true --- -This page is deprecated: it documents the knowledge and memory API, which unified databases replace. See [Ingest context](/essentials/v2/ingest) and [Query](/essentials/v2/query). - Go from zero to a production AI assistant that answers "why was this built this way?" in four phases. Start with one file and a real search query. End with a FastAPI backend that ingests GitHub, PRs, Slack, and RFCs, then generates GPT-4o answers grounded in your codebase. -> **How this guide is structured.** Each phase ends with something that works. Phase 0 is a complete minimal system in under 10 minutes. Phases 1–3 are progressive upgrades. You never need to redo what came before. +> **How this guide is structured.** Each phase ends with something that works. Phase 0 is a complete minimal system in under 10 minutes. Phases 1-3 are progressive upgrades. You never need to redo what came before. -> **All code in this cookbook is real.** Base URL is `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). +> **All code in this cookbook is real.** Base URL is `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). SDK snippets use the call shapes in the [SDK reference](/api-reference/v2/sdks) and require an SDK release generated from the current API specification. --- @@ -33,7 +30,7 @@ By the end of this cookbook, you'll be able to: --- -## Phase 0 - Minimal Working System · 5–10 minutes +## Phase 0 - Minimal Working System · 5-10 minutes The only goal is to see a real search response from a real file you uploaded. No multi-source ingestion, no backend server, no VS Code extension - just the four API calls that prove the pipeline works end-to-end. @@ -95,7 +92,15 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from config import client, DATABASE_ID def create_tenant(): - client.databases.create(database=DATABASE_ID) + client.databases.create( + database=DATABASE_ID, + database_metadata_schema=[ + {"name": "doc_type", "data_type": "VARCHAR"}, + {"name": "repo", "data_type": "VARCHAR"}, + {"name": "language", "data_type": "VARCHAR"}, + {"name": "channel", "data_type": "VARCHAR"}, + ], + ) print("Accepted") _poll_until_ready() @@ -105,9 +110,9 @@ def _poll_until_ready(timeout=180, interval=4): while time.time() < deadline: status = client.databases.status(database=DATABASE_ID).data infra = status.infra - if infra.scheduler_status and infra.graph_status and infra.vectorstore_status.knowledge and infra.vectorstore_status.memories: + if infra.ready_for_ingestion: print("✓ Database ready."); return - print(f" scheduler={infra.scheduler_status} graph={infra.graph_status} vectorstore={infra.vectorstore_status} - retrying in {interval}s") + print(f" ready_for_ingestion={infra.ready_for_ingestion} - retrying in {interval}s") time.sleep(interval) raise TimeoutError("Database not ready within 180s") @@ -123,7 +128,7 @@ python3 phase0/create_tenant.py ``` Accepted: {'database': 'engineering-docs', 'status': 'accepted', 'message': 'Database accepted. Poll /databases/status for readiness.'} Polling for readiness... - scheduler=False graph=False vectorstore=[False, False] - retrying in 4s + ready_for_ingestion=False - retrying in 4s ✓ Database ready. ``` @@ -133,9 +138,9 @@ Polling for readiness... ### Step 2 - Upload One File -**What:** Uploads a single file using **multipart form-data** to `/context/ingest` - the recommended beginner path. HydraDB handles chunking, embedding, and graph-node creation automatically. The returned `id` is what you use in Step 3 to verify indexing. +**What:** Reads a file's text and ingests it as a context item via `POST /context/ingest`. HydraDB handles chunking, embedding, and graph-node creation automatically. The `context_id` you set is what you use in Step 3 to verify indexing. -> 💡 **Two ingestion modes exist.** This step uses **multipart file upload via `/context/ingest`** - the tested beginner path. An advanced JSON body mode (used in Phases 1–2) supports structured IDs and explicit graph `relations`. Use file upload here first. +> 💡 **There is no file upload on a unified database.** You send the file's text inside a `context` item. Text-based files (Markdown, code, JSON) work as-is; binary formats need a text-extraction library client-side. First, create a sample document: @@ -159,76 +164,85 @@ EOF ``` ```python -import sys, os +import sys, os, json sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from config import client, DATABASE_ID -def upload_file(filepath: str): +def upload_file(filepath: str, context_id: str): filename = os.path.basename(filepath) + text = open(filepath, encoding="utf-8", errors="ignore").read() - with open(filepath, "rb") as f: - result = client.context.ingest( - database=DATABASE_ID, - documents=[(filename, f, "text/markdown")], - ) + result = client.context.ingest( + database=DATABASE_ID, + collection="default", + context=json.dumps([{ + "context_id": context_id, + "title": filename, + "text": text, + }]), + ) print("RESULT:", result) return result if __name__ == "__main__": - result = upload_file("phase0/sample_docs/auth_middleware.md") - # Note the id from the response - you need it for Step 3 - print("\nCopy your id for use in verify.py:", result) + result = upload_file("phase0/sample_docs/auth_middleware.md", + "myrepo/auth_middleware.md") + # Note the context_id you set - you need it for Step 3 + print("\nUse 'myrepo/auth_middleware.md' as ITEM_ID in verify.py") ``` **Expected output:** ``` -STATUS CODE: 200 -RESPONSE: {"results": [{"id": "YOUR_ID_HERE", "filename": "auth_middleware.md", "status": "accepted", "error": null}]} +RESULT: {"results": [{"id": "myrepo/auth_middleware.md", "status": "accepted"}]} -Copy your id for use in verify.py: {'results': [{'id': 'YOUR_ID_HERE', 'filename': 'auth_middleware.md', 'status': 'accepted', 'error': None}]} +Use 'myrepo/auth_middleware.md' as ITEM_ID in verify.py ``` -The exact value of `id` depends on HydraDB's internal file registration. Copy the value returned and use it in Step 3. +You set `context_id` yourself, so the ID to verify in Step 3 is stable and known up front. **If it fails:** - `404 Database does not exist` - Step 1 not complete, or `database` mismatch. -- `400 / missing files` - Confirm the field name is `documents` (not `file`), and that `Content-Type` is NOT manually set in headers. +- `400` naming an unknown field - the request must send `context` as a JSON string containing the item array. Each item takes `text` or `conversation`, never both. --- ### Step 3 - Verify Indexing -**What:** Polls `/context/status` using the `id` returned in Step 2. HydraDB returns a `statuses` array; you read `statuses[0].indexing_status`. Querying before this reaches `"completed"` returns empty results with no error - the most common beginner confusion. +**What:** Polls `GET /context/status` using the `context_id` set in Step 2. HydraDB returns a `statuses` array; you read `statuses[0].indexing_status`. Querying before this reaches `"completed"` returns empty results with no error - the most common beginner confusion. -Replace `YOUR_ID_HERE` with the `id` from Step 2: +Use the `context_id` from Step 2: ```python -import sys, os, time +import sys, os, time, requests sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) -from config import client, DATABASE_ID +from config import HYDRA_DB_API_KEY, DATABASE_ID -# Replace with the id returned by upload_file.py in Step 2 -ITEM_ID = "YOUR_ID_HERE" +BASE_URL = "https://api.hydradb.com" +HEADERS = {"Authorization": f"Bearer {HYDRA_DB_API_KEY}", "API-Version": "2"} -def verify_item(id: str, timeout: int = 120, interval: int = 3): +# The context_id set by upload_file.py in Step 2 +ITEM_ID = "myrepo/auth_middleware.md" + +def verify_file(item_id: str, timeout: int = 120, interval: int = 3): """ - Polls /context/status until indexing_status is "completed" or "errored". + Polls GET /context/status until indexing_status is "completed" or "errored". """ - print(f"Verifying '{id}'...") + print(f"Verifying '{item_id}'...") deadline = time.time() + timeout while time.time() < deadline: - status = client.context.status( - database=DATABASE_ID, - ids=[id], + resp = requests.get( + f"{BASE_URL}/context/status", + headers=HEADERS, + params={"database": DATABASE_ID, "ids": item_id}, + timeout=15, ) - print("STATUS:", status) - - items = status.data.statuses or [] + resp.raise_for_status() + items = resp.json().get("data", {}).get("statuses") or [] if items: - s = items[0].indexing_status - if s == "completed": print(f"✓ '{id}' ready."); return + s = items[0].get("indexing_status") + if s == "completed": print(f"✓ '{item_id}' ready."); return elif s == "errored": print("Indexing errored."); return print(f" indexing_status: {s} - waiting...") @@ -236,41 +250,37 @@ def verify_item(id: str, timeout: int = 120, interval: int = 3): raise TimeoutError("Indexing timed out") if __name__ == "__main__": - verify_item(ITEM_ID) + verify_file(ITEM_ID) ``` **Expected output:** ``` -Verifying 'YOUR_ID_HERE'... -STATUS CODE: 200 -RESPONSE: {"statuses": [{"id": "YOUR_ID_HERE", "indexing_status": "processing"}]} +Verifying 'myrepo/auth_middleware.md'... indexing_status: processing - waiting... -STATUS CODE: 200 -RESPONSE: {"statuses": [{"id": "YOUR_ID_HERE", "indexing_status": "completed"}]} -✓ 'YOUR_ID_HERE' ready. + indexing_status: graph_creation - waiting... +✓ 'myrepo/auth_middleware.md' ready. ``` --- ### Step 4 - Run Your First Search Query -**What:** Sends a POST to `/query` and receives a `chunks` array. Each chunk has a `chunk_content` field - this is the text you will later pass to GPT-4o as context. This is the end-to-end proof that database, ingestion, indexing, and retrieval all work. +**What:** Sends a POST to `/query` and receives a `chunks` array. Each chunk has a `content` field - this is the text you will later pass to GPT-4o as context. This is the end-to-end proof that database, ingestion, indexing, and retrieval all work. **Validated first search - minimal working request:** -```jsonc +```json { "database": "engineering-docs", + "collection": "default", "query": "internal IP auth skip logic", "max_results": 10 } - -// collection is NOT required for this working flow. -// HydraDB handles scope internally when omitted. -// Response contains a "chunks" array - read chunk_content from each item. ``` +Response `data` has four keys: `chunks`, `graph`, `forceful_relations`, `llm_prompt`. Read `content` from each item in `chunks`, or pass `data.llm_prompt` to your LLM as-is. + ```python import sys, os sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) @@ -279,12 +289,11 @@ from config import client, DATABASE_ID def search(query: str, max_results: int = 10): """ Minimal working search. - Only database + query + max_results are required. - collection is NOT required - HydraDB handles scope internally. - Returns SDK object with .chunks and .sources attributes. + Returns SDK object; data has chunks, graph, forceful_relations, llm_prompt. """ data = client.query( database=DATABASE_ID, + collection="default", query=query, max_results=max_results, ) @@ -292,98 +301,94 @@ def search(query: str, max_results: int = 10): if __name__ == "__main__": data = search("internal IP auth skip logic") - chunks = data.data.chunks or [] # iterate the "chunks" array - sources = data.data.sources or [] # sources list for citations + chunks = data.data.chunks or [] # iterate the "chunks" array print(f"\nChunks returned: {len(chunks)}") for i, chunk in enumerate(chunks, 1): - chunk_content = chunk.chunk_content or "" # always use chunk_content - score = chunk.relevancy_score if chunk.relevancy_score is not None else "?" - print(f"\n[{i}] relevancy_score={score}") - print(f" {chunk_content[:300]}") - - print(f"\nSources: {[s.title for s in sources]}") + content = chunk.content or "" + score = chunk.score if chunk.score is not None else "?" + print(f"\n[{i}] score={score} context_id={chunk.context_id}") + print(f" {content[:300]}") + + # Prompt-ready markdown with citation labels - pass to your LLM as-is + print("\n--- llm_prompt preview ---") + print((data.data.llm_prompt or "")[:500]) ``` **Expected output:** ``` -STATUS CODE: 200 - Chunks returned: 2 -[1] relevancy_score=0.94 +[1] score=0.94 context_id=myrepo/auth_middleware.md Token validation is skipped for requests from internal IP ranges (10.0.0.0/8 and 172.16.0.0/12). Service-to-service calls within the VPC caused circular dependency issues during startup... -[2] relevancy_score=0.87 +[2] score=0.87 context_id=myrepo/auth_middleware.md The security team approved this exception in RFC-007, on the condition that internal network access is controlled at the VPC level. -Sources: ['auth_middleware.md'] +--- llm_prompt preview --- +[1] Token validation is skipped for requests from internal IP ranges ... ``` **If it fails:** `chunks: []` - Indexing is not yet complete. Wait 30 seconds and retry. Re-run Step 3 to confirm `indexing_status: completed`. -> ✅ **Phase 0 complete.** The same `/query` endpoint - with the same minimal three-field body - is what every later phase uses. You'll only add parameters, not change the structure. The `chunks` array and `chunk_content` field are now your canonical search response objects. +> ✅ **Phase 0 complete.** The same `/query` endpoint is what every later phase uses. You'll only add parameters, not change the structure. The `chunks` array and `llm_prompt` field are now your canonical search response objects. -**What you just built:** You now have a working **retrieval system**. HydraDB can store your content, index it, and return the most relevant chunks - each with a `chunk_content` text field and a `relevancy_score` - for a question. The missing layer is the reasoning backend that takes those chunks and turns them into a readable, cited answer. +**What you just built:** You now have a working **retrieval system**. HydraDB can store your content, index it, and return the most relevant chunks - each with `content`, `context_id` and a `score` - plus a prompt-ready `llm_prompt` block for a question. The missing layer is the reasoning backend that turns that context into a readable, cited answer. | What works now | What comes next | |---|---| -| Database creation | Better metadata and chunk quality | -| File upload | More source types for deeper context | +| Database creation | Better attributes and chunk quality | +| Text ingestion | More source types for deeper context | | Indexing verification | GPT-4o answer generation on top of search | -| Search returns chunks and sources | A backend and UI your team can use daily | +| Search returns chunks and llm_prompt | A backend and UI your team can use daily | --- -## Phase 1 - Improve Retrieval · 15–20 minutes +## Phase 1 - Improve Retrieval · 15-20 minutes -Ingest multiple files with structured metadata, use collections to organise content, and tune search parameters. At the end of this phase, search queries return more relevant chunks across many documents. +Ingest multiple files with structured attributes, use collections to organise content, and tune search parameters. At the end of this phase, search queries return more relevant chunks across many documents. -**Goal:** 50+ files indexed with metadata, scoped search working. +**Goal:** 50+ files indexed with attributes, scoped search working. ### How Retrieval Works Before writing more ingestion code, it's worth understanding what HydraDB actually returns and how to interpret it. This mental model applies to every phase. -**Chunks vs. sources:** When HydraDB indexes a document, it splits the content into overlapping **chunks**. Each chunk is embedded independently and stored as a node in the context graph. When you call `/query`, you get back a `chunks` array. Iterate this array and read `chunk_content` from each item - that is the text you pass directly to your LLM as context. - -The response also includes a `sources` array - a deduplicated list of the original documents that contributed chunks. Use `sources` for citation labels; use `chunks` for the actual LLM context. +**Chunks vs. llm_prompt:** When HydraDB indexes a context item, it splits the content into overlapping **chunks**. Each chunk is embedded independently and stored as a node in the context graph. When you call `/query`, `data` has exactly four keys: `chunks`, `graph`, `forceful_relations`, `llm_prompt`. Iterate `chunks` and read `content` from each item if you want to build context yourself, or pass `llm_prompt` to your LLM as-is - it is a ready-made markdown block with citation labels (`[1]`, `[R1]`, `[P1]`). **Anatomy of a search chunk - field reference:** -| Field | Required | Description | -|---|---|---| -| `chunk_content` | **required** | The actual text of this chunk. **This is the canonical field you must extract.** Pass this directly to your LLM as context. It is the only field you cannot skip. | -| `relevancy_score` | optional | Higher is more relevant. Use it for ranking, filtering low-quality chunks (drop below 0.5), or deciding how many chunks to pass into the LLM context window. | -| `title` | optional | Human-readable label from the source document. Use for `[Source: ...]` citation references in your final answer. | -| `chunk_uuid` | optional | Unique identifier for this chunk. Useful for deduplication when fan-out search across collections returns the same chunk more than once. | -| `url` | optional | The URL you set at ingestion time. Present only if supplied. Use for deep-links in citation UI. | -| `additional_metadata` | optional | The per-source metadata object from ingestion. May include `doc_type`, `repo`, `pr_number`, etc. | +| Field | Description | +|---|---| +| `content` | The actual text of this chunk. **This is the canonical field you must extract** when building context by hand. | +| `score` | Higher is more relevant. Use it for ranking, filtering low-quality chunks (drop below 0.5), or deciding how many chunks to pass into the LLM context window. | +| `context_id` | The ID of the context item this chunk came from. Use for `[Source: ...]` citation references in your final answer. | +| `chunk_id` | Unique identifier for this chunk. Useful for deduplication when fan-out search across collections returns the same chunk more than once. | +| `enrichment` | Extracted signals attached to the chunk (preferences, decisions), when `enrich` ran at ingest time. | +| `temporal` | Time information parsed from `happened_at`, when present. | -**How to interpret the `relevancy_score`:** Scores are relative within a single response - they indicate ranked relevance for your specific query, not absolute confidence. +**How to interpret the `score`:** Scores are relative within a single response - they indicate ranked relevance for your specific query, not absolute confidence. -| relevancy_score range | What it means | What to do | +| score range | What it means | What to do | |---|---|---| | `0.85+` | High confidence - chunk directly answers the query | Always include in LLM context | -| `0.65–0.85` | Good match - chunk is relevant, may not be the exact answer | Include, let LLM decide relevance | -| `0.40–0.65` | Weak match - tangentially related | Include only if few high-score results | +| `0.65-0.85` | Good match - chunk is relevant, may not be the exact answer | Include, let LLM decide relevance | +| `0.40-0.65` | Weak match - tangentially related | Include only if few high-score results | | `below 0.40` | Low match - probably not relevant | Drop from context to reduce noise | -**How graph context changes search:** When you add `"graph_context": true` to your search request, HydraDB walks the explicit `relations.ids` edges you set at ingestion time for every high-scoring chunk. A source file chunk can pull in the PR that last changed it. That PR can pull in the RFC it referenced. This multi-hop traversal is what makes "why" questions answerable. - -> 💡 **Always safe to add to Phase 0.** `"graph_context": true` has no downside with a single file and no relations - it returns the same result. Turn it on now. +**How graph context changes search:** `graph_context` defaults to `true`. HydraDB walks the explicit `forceful_relations` edges you set at ingestion time for every high-scoring chunk. A source file chunk can pull in the PR that last changed it. That PR can pull in the RFC it referenced. This multi-hop traversal is what makes "why" questions answerable. The `graph` array in the response carries the triplets and `path_summary` it found. --- -### P1 · Step 1 - Batch Upload with Explicit IDs (JSON Ingestion) +### P1 · Step 1 - Batch Upload with Explicit IDs -**What:** Switches to **JSON body ingestion** via `/context/ingest` with `app_knowledge` field - the advanced ingestion path. This gives you full control over IDs, timestamps, collections, metadata, and (in Phase 2) explicit graph `relations`. The `{repo_name}/{relative_path}` ID convention is required for Phase 2 graph linking. +**What:** Ingests a whole repo as context items via `/context/ingest`. This gives you full control over IDs, timestamps, attributes, and (in Phase 2) explicit `forceful_relations` graph edges. The `{repo_name}/{relative_path}` `context_id` convention is required for Phase 2 graph linking. -> ⚠️ **Advanced path - stricter validation.** JSON ingestion requires well-formed payloads with `id`, `type`, and `content.text` on every item. Malformed requests are rejected outright. Use the file upload path (Phase 0 Step 2) if you just want to get content indexed quickly. Use JSON ingestion when you need stable IDs and graph relations. +> ⚠️ **Strict validation.** Every item must have `text` or `conversation`, and unknown fields are rejected with a 400. The `context` list holds at most 100 items per request. ```python import sys, os, json, time, subprocess, pathlib @@ -399,13 +404,14 @@ def git_timestamp(repo_path: str, rel_path: str) -> str: raw = subprocess.check_output( ["git","log","-1","--format=%cI",rel_path], cwd=repo_path, stderr=subprocess.DEVNULL).decode().strip() - return raw or "2020-01-01T00:00:00Z" - except: return "2020-01-01T00:00:00Z" + return (raw or "2020-01-01")[:10] + except: return "2020-01-01" -def upload_batch(batch: list) -> list: +def upload_batch(batch: list, collection: str) -> list: result = client.context.ingest( database=DATABASE_ID, - app_knowledge=json.dumps(batch), + collection=collection, + context=json.dumps(batch), # JSON string, not a list ) ids = [r.id for r in (result.data.results or []) if r.id] print(f" Uploaded {len(ids)} items") @@ -424,18 +430,20 @@ def ingest_directory(repo_path: str, repo_name: str) -> list: try: content = f.read_text(encoding="utf-8", errors="ignore") except: continue batch.append({ - "id": f"{repo_name}/{rel}", # Phase 2 PR relations reference this ID - "title": rel, - "type": "document", - "timestamp": git_timestamp(str(root), rel), - "content": {"text": content}, - "collections": ["codebase", repo_name, f.suffix.lstrip(".")], - "metadata": {"doc_type":"source_file","repo":repo_name, - "language":f.suffix.lstrip("."),"tags":["codebase",repo_name]}, + "context_id": f"{repo_name}/{rel}", # Phase 2 PR relations reference this ID + "title": rel, + "text": content, + "happened_at": git_timestamp(str(root), rel), + "attributes": { + "doc_type": "source_file", + "repo": repo_name, + "language": f.suffix.lstrip("."), + }, + "custom_attributes": {"tags": ["codebase", repo_name]}, }) - if len(batch) == 20: - all_ids += upload_batch(batch); batch = []; time.sleep(1) - if batch: all_ids += upload_batch(batch) + if len(batch) == 100: + all_ids += upload_batch(batch, "codebase"); batch = [] + if batch: all_ids += upload_batch(batch, "codebase") print(f"Verifying {len(all_ids)} files...") verify_batch(all_ids) print(f"✓ {len(all_ids)} files indexed from '{repo_name}'") @@ -448,34 +456,35 @@ if __name__ == "__main__": **Expected output:** ``` - Uploaded 20 items - Uploaded 20 items - Uploaded 14 items + Uploaded 54 items Verifying 54 files... ✓ 54 files indexed from 'myrepo' ``` --- -### P1 · Step 2 - Metadata and Collections +### P1 · Step 2 - Attributes and Collections + +**What:** A `collection` is the scope you set per ingest request - use it for the source type (`codebase`, `pull-requests`, `slack`, `wikis`) so search can fan out per source. `attributes` are the schema-backed filterable fields declared at database creation (`doc_type`, `repo`, `language`, `channel`). `custom_attributes` carries anything else you want stored with the item. No extra API calls needed - these fields go in the ingestion payload. -**What:** `collections` are labels you define for scoping search. `additional_metadata` fields are arbitrary key-value pairs used for filtering and citation labels. No extra API calls needed - these fields go in the ingestion payload. +Recommended `collection` values: `codebase`, `pull-requests`, `slack`, `wikis`. Recommended `doc_type` attribute values: `source_file`, `pull_request`, `slack_thread`, `rfc`, `adr`, `wiki`, `runbook`. -```jsonc +```json { - "collections": ["codebase", "myrepo", "py"], - // [0] source type: "codebase" | "pull-requests" | "slack" | "wikis" - // [1] repo/channel: "myrepo" | "eng-architecture" - // [2] sub-category: extension, doc_type, etc. - - "metadata": { - "doc_type": "source_file", - // Recommended values: - // source_file | pull_request | slack_thread | rfc | adr | wiki | runbook - "repo": "myrepo", - "language": "py", - "tags": ["codebase", "auth"] - } + "collection": "codebase", + "context": [{ + "context_id": "myrepo/auth/middleware.py", + "title": "auth/middleware.py", + "text": "...", + "attributes": { + "doc_type": "source_file", + "repo": "myrepo", + "language": "py" + }, + "custom_attributes": { + "tags": ["codebase", "auth"] + } + }] } ``` @@ -488,8 +497,8 @@ import sys, os sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from config import client, DATABASE_ID -def tuned_recall(query: str, scope: str = None, max_results: int = 15): - """scope: optional collection. Omit to search all collections automatically.""" +def tuned_recall(query: str, collections: list = None, max_results: int = 15): + """collections: optional list to scope search. Omit to search everything.""" kwargs = { "database": DATABASE_ID, "query": query, @@ -499,44 +508,41 @@ def tuned_recall(query: str, scope: str = None, max_results: int = 15): "alpha": 0.75, "recency_bias": 0.2, } - if scope: kwargs["collection"] = scope + if collections: kwargs["collections"] = collections return client.query(**kwargs) -def build_context(chunks: list, min_score: float = 0.5) -> tuple[str, list]: +def build_context(result, min_score: float = 0.5) -> tuple[str, list]: """ - Extract chunk_content from chunks and build LLM context string. - Filters by relevancy_score; deduplicates by chunk_uuid. + Build LLM context from a /query result. + Filters chunks by score; deduplicates by chunk_id. Returns: (context_text, sources_list) """ parts, sources, seen = [], [], set() - for chunk in chunks: - relevancy_score = chunk.relevancy_score if chunk.relevancy_score is not None else 1 - if relevancy_score < min_score: continue - chunk_content = chunk.chunk_content or "" # canonical field - uid = chunk.chunk_uuid or chunk_content[:40] - if not chunk_content or uid in seen: continue + for chunk in (result.data.chunks or []): + score = chunk.score if chunk.score is not None else 1 + if score < min_score: continue + content = chunk.content or "" + uid = chunk.chunk_id or content[:40] + if not content or uid in seen: continue seen.add(uid) - addl = chunk.additional_metadata - doc_type = (addl or {}).get("doc_type", "doc") - title = chunk.source_title or "untitled" - parts.append(f"[{doc_type.upper()}] {title}\n{chunk_content}") - sources.append({"title": title, "url": chunk.url, - "relevancy_score": chunk.relevancy_score, - "chunk_uuid": chunk.chunk_uuid}) + parts.append(f"[{chunk.context_id}]\n{content}") + sources.append({"context_id": chunk.context_id, + "score": chunk.score, + "chunk_id": chunk.chunk_id}) return "\n\n---\n\n".join(parts), sources ``` --- -## Phase 2 - Multi-source Context · 20–30 minutes +## Phase 2 - Multi-source Context · 20-30 minutes -Connect GitHub source files to their pull requests, Slack decision threads, and internal wikis. Use explicit `relations.ids` to guarantee graph edges. When a developer asks "why?", the answer now travels across all four source types in a single query. +Connect GitHub source files to their pull requests, Slack decision threads, and internal wikis. Use explicit `forceful_relations` to guarantee graph edges. When a developer asks "why?", the answer now travels across all four source types in a single query. **Goal:** A "why" question returns code + PR + Slack + RFC in one response. **Pipeline:** Source files → Pull requests → Slack threads → Wikis & RFCs → Graph built -> 💡 **Explicit vs. automatic graph edges.** Setting `relations.ids` *guarantees* a graph link every time. HydraDB may also try to extract relationships from content, but this is best-effort. Always use explicit relations for links that matter. +> 💡 **Explicit vs. automatic graph edges.** Setting `forceful_relations` on an item *guarantees* a graph link to every listed `context_id`. HydraDB may also try to extract relationships from content, but this is best-effort. Always use forceful relations for links that matter. ### Why Each Source Type Matters @@ -545,7 +551,7 @@ A developer asking "why does the auth middleware skip token validation for inter | Source | Answers | Role | Without it | |---|---|---|---| | **Source files** | *What* the code does | Anchors the query. Entry node for graph traversal. | No traversal starting point | -| **Pull requests** | *Why* the change happened | Intent and debate behind the change. Review comments capture rejected alternatives. Set `relations.ids` to the changed file IDs. | Code exists but has no rationale | +| **Pull requests** | *Why* the change happened | Intent and debate behind the change. Review comments capture rejected alternatives. Set `forceful_relations` to the changed file IDs. | Code exists but has no rationale | | **Slack threads** | Discussion and trade-offs | Captures informal approvals and decisions never written up in docs. | Informal approvals are invisible | | **Wikis & RFCs** | Formal decision record | Authoritative rationale and approval chain. An RFC directly answers a whole class of "why" questions. | Missing the authoritative "because" | @@ -564,7 +570,7 @@ When those sources share stable IDs and explicit relations, the assistant can mo ### P2 · Step 1 - Ingest GitHub Source Files with Consistent IDs -**What:** Ingests source files using the `{repo_name}/{relative_path}` ID convention. These IDs are what PR ingestion will reference in `relations.ids`. The IDs must match exactly - `myrepo/auth/middleware.py`, not `auth/middleware.py`. +**What:** Ingests source files using the `{repo_name}/{relative_path}` `context_id` convention. These IDs are what PR ingestion will reference in `forceful_relations.context_ids`. The IDs must match exactly - `myrepo/auth/middleware.py`, not `auth/middleware.py`. Use `ingest_directory()` from Phase 1 Step 1. If you already ran Phase 1, your files are already indexed. @@ -580,7 +586,7 @@ ingest_directory('/path/to/your/repo', 'myrepo') ### P2 · Step 2 - Ingest Pull Requests with Explicit Graph Relations -**What:** Fetches merged PRs and turns each into one document: title + description + review comments + changed-file list. The `relations.ids` field guarantees graph edges form between the PR and every source file it changed. +**What:** Fetches merged PRs and turns each into one context item: title + description + review comments + changed-file list. The `forceful_relations.context_ids` field guarantees graph edges form between the PR and every source file it changed. **Before:** A GitHub token with `repo` scope. Create at `github.com/settings/tokens` → New classic token → check `repo` → add to `.env` as `GITHUB_TOKEN`, `GITHUB_OWNER`, `GITHUB_REPO`. @@ -624,7 +630,7 @@ def ingest_pull_requests(prs: list, repo_name: str) -> list: changed = [f["filename"] for f in pr.get("files", [])] reviews = "\n\n".join(r["body"] for r in pr.get("reviews",[]) if r.get("body")) comments = "\n\n".join(c["body"] for c in pr.get("comments",[]) if c.get("body")) - # IDs must exactly match those set in ingest_directory() + # IDs must exactly match the context_ids set in ingest_directory() ids = [f"{repo_name}/{fname}" for fname in changed] content = ( f"PR #{pr['number']}: {pr['title']}\n" @@ -635,18 +641,20 @@ def ingest_pull_requests(prs: list, repo_name: str) -> list: f"Inline comments:\n{comments or '(none)'}" ) batch.append({ - "id": f"pr-{pr['number']}", - "title": f"PR #{pr['number']}: {pr['title']}", - "type": "document", - "timestamp": pr["merged_at"], - "content": {"text": content}, - "collections": ["pull-requests"], - "relations": {"ids": ids}, - "metadata": {"doc_type":"pull_request","pr_number":pr["number"], - "author":pr["user"]["login"],"changed_files":changed}, + "context_id": f"pr-{pr['number']}", + "title": f"PR #{pr['number']}: {pr['title']}", + "text": content, + "happened_at": (pr["merged_at"] or "")[:10], + "attributes": {"doc_type": "pull_request", "repo": repo_name}, + "custom_attributes": { + "pr_number": pr["number"], + "author": pr["user"]["login"], + "changed_files": changed, + }, + "forceful_relations": {"context_ids": ids}, }) - if len(batch)==20: all_ids+=upload_batch(batch); batch=[]; time.sleep(1) - if batch: all_ids+=upload_batch(batch) + if len(batch)==100: all_ids+=upload_batch(batch, "pull-requests"); batch=[] + if batch: all_ids+=upload_batch(batch, "pull-requests") verify_batch(all_ids) print(f"✓ {len(all_ids)} PRs indexed") return all_ids @@ -690,20 +698,18 @@ def ingest_slack_export(export_dir: str, channels: list[str]) -> list: f"[{m.get('user','?')}]: {m.get('text','')}" for m in msgs if m.get("text")) if not text.strip(): continue - ts_dt = datetime.fromtimestamp(float(thread_ts)).isoformat() + "Z" + ts_dt = datetime.fromtimestamp(float(thread_ts)).isoformat() batch.append({ - "id": f"slack-{channel}-{thread_ts}", - "title": f"Slack - #{channel} - {ts_dt[:10]}", - "type": "document", - "timestamp": ts_dt, - "content": {"text": f"Channel: #{channel}\n\n{text}"}, - "collections": ["slack", channel], - "metadata": {"doc_type":"slack_thread","channel":channel, - "message_count":len(msgs)}, + "context_id": f"slack-{channel}-{thread_ts}", + "title": f"Slack - #{channel} - {ts_dt[:10]}", + "text": f"Channel: #{channel}\n\n{text}", + "happened_at": ts_dt[:10], + "attributes": {"doc_type": "slack_thread", "channel": channel}, + "custom_attributes": {"message_count": len(msgs)}, }) - if len(batch)==20: all_ids+=upload_batch(batch); batch=[]; time.sleep(1) + if len(batch)==100: all_ids+=upload_batch(batch, "slack"); batch=[] print(f" Processed #{channel}: {len(threads)} threads") - if batch: all_ids+=upload_batch(batch) + if batch: all_ids+=upload_batch(batch, "slack") verify_batch(all_ids) print(f"✓ {len(all_ids)} Slack threads indexed") return all_ids @@ -736,17 +742,18 @@ def ingest_wikis(pages: list) -> list: batch, all_ids = [], [] for page in pages: batch.append({ - "id": f"wiki-{page['id']}", - "title": page["title"], - "type": "document", - "timestamp": page["last_updated"], - "content": {"text": page["content"]}, - "url": page.get("url", ""), - "collections": ["wikis", page["doc_type"]], - "metadata": {"doc_type":page["doc_type"],"author":page.get("author","")}, + "context_id": f"wiki-{page['id']}", + "title": page["title"], + "text": page["content"], + "happened_at": page["last_updated"][:10], + "attributes": {"doc_type": page["doc_type"]}, + "custom_attributes": { + "url": page.get("url", ""), + "author": page.get("author", ""), + }, }) - if len(batch)==20: all_ids+=upload_batch(batch); batch=[]; time.sleep(1) - if batch: all_ids+=upload_batch(batch) + if len(batch)==100: all_ids+=upload_batch(batch, "wikis"); batch=[] + if batch: all_ids+=upload_batch(batch, "wikis") verify_batch(all_ids) print(f"✓ {len(all_ids)} wiki/RFC pages indexed") return all_ids @@ -760,7 +767,7 @@ def ingest_markdown_folder(folder: str) -> list: "title": f.stem.replace("-"," ").replace("_"," ").title(), "content": f.read_text(encoding="utf-8",errors="ignore"), "doc_type": "rfc" if "rfc" in f.name.lower() else "wiki", - "last_updated": "2024-01-01T00:00:00Z", + "last_updated": "2024-01-01", }) return ingest_wikis(pages) ``` @@ -777,10 +784,12 @@ from config import client, DATABASE_ID def multi_source_recall(question: str): return client.query( database=DATABASE_ID, + collections=["codebase", "pull-requests", "slack", "wikis"], query=question, max_results=15, mode="thinking", graph_context=True, + follow_forceful_relations=True, alpha=0.65, recency_bias=0.15, ) @@ -789,19 +798,23 @@ if __name__ == "__main__": result = multi_source_recall( "Why does the auth middleware skip token validation for internal IPs?") for chunk in (result.data.chunks or [])[:5]: - score = chunk.relevancy_score if chunk.relevancy_score is not None else 0 - print(f"[{score:.2f}] {chunk.source_title or ''}") - print((chunk.chunk_content or "")[:240]) + score = chunk.score if chunk.score is not None else 0 + print(f"[{score:.2f}] {chunk.context_id}") + print((chunk.content or "")[:240]) + # Forceful-relation hops land in data.forceful_relations; the + # traversal paths land in data.graph (triplets + path_summary). + for hop in (result.data.forceful_relations or []): + print("via:", hop.get("via"), "->", (hop.get("chunk") or {}).get("context_id")) ``` **Expected output:** ``` -[0.88] RFC-007 Internal Service Auth +[0.88] wiki-rfc-007 The auth middleware skips token validation for internal IPs... -[0.82] PR #142 Auth Startup Fix +[0.82] pr-142 The change resolved circular dependency issues at startup... -[0.77] #eng-architecture +[0.77] slack-eng-architecture-1707926400.000001 Security approved the internal-network exception... ``` @@ -809,7 +822,7 @@ Security approved the internal-network exception... --- -## Phase 3 - Backend & Answer Generation · 25–35 minutes +## Phase 3 - Backend & Answer Generation · 25-35 minutes Build a production FastAPI server. HydraDB is the memory layer - it retrieves ranked `chunks` via `search_docs()`. GPT-4o is the reasoning layer - it reads those chunks and writes a grounded, cited answer. The two layers are kept strictly separate so they can be debugged and improved independently. @@ -820,8 +833,8 @@ Build a production FastAPI server. HydraDB is the memory layer - it retrieves ra The flow through the backend is: 1. **Question** - `POST /chat` or `/ask` -2. **Search** - `search_docs()` → chunks -3. **Context block** - `chunk_content` assembled +2. **Search** - `search_docs()` → chunks + llm_prompt +3. **Context block** - `llm_prompt` passed through 4. **GPT-4o** - writes grounded answer 5. **Response** - answer + citations @@ -834,7 +847,7 @@ backend/ ├── __init__.py - makes backend a package so imports work ├── config.py - env vars, HYDRA_DB_API_KEY, HydraDB client, OpenAI keys, search defaults ├── hydra_client.py - search_docs() wrapper for HydraDB retrieval -├── search.py - recall_context() and build_context_block(); extracts chunk_content +├── search.py - recall_context() and build_context_block(); passes llm_prompt through ├── answer.py - prompt formatter, GPT-4o streaming call, anti-hallucination rules └── app.py - FastAPI server; /chat (streaming) and /ask (sync JSON, easier for Postman) ``` @@ -842,8 +855,8 @@ backend/ **Architecture summary:** - **`config.py`** - Loads `.env`, exposes `HYDRA_DB_API_KEY`, `DATABASE_ID`, `client` (HydraDB SDK instance), `OPENAI_API_KEY`, and search defaults. Every other file imports from here only. -- **`hydra_client.py`** - `search_docs()` is the HydraDB retrieval path. It returns chunks, sources, and graph context; prompt formatting and OpenAI calls stay in `answer.py`. Imported by → `search.py`, `app.py`. -- **`search.py`** - `recall_context()` calls `search_docs()` and returns the raw payload. `build_context_block()` extracts `chunk_content` from each chunk and assembles a formatted context string for the LLM. Imported by → `app.py`. +- **`hydra_client.py`** - `search_docs()` is the HydraDB retrieval path. It returns chunks, graph, forceful_relations and llm_prompt; prompt formatting and OpenAI calls stay in `answer.py`. Imported by → `search.py`, `app.py`. +- **`search.py`** - `recall_context()` calls `search_docs()` and returns the raw payload. `build_context_block()` takes the prompt-ready `llm_prompt` from the response and falls back to assembling `content` from chunks when it is empty. Imported by → `app.py`. - **`answer.py`** - Takes the context block from `search.py`, formats the system + user prompt, and calls the OpenAI streaming API. Contains all anti-hallucination rules. Never calls HydraDB. Imported by → `app.py`. - **`app.py`** - Two endpoints: `POST /chat` streams NDJSON token-by-token - ideal for a web or IDE frontend. `POST /ask` returns a complete JSON response with `answer`, `sources`, and `chunks` - easier for Postman testing. Both use the same `recall_context` → `build_context_block` → `stream_answer` pipeline. @@ -904,23 +917,27 @@ RECALL_ALPHA = 0.75 # semantic vs keyword balance from backend.config import client, DATABASE_ID, RECALL_MAX_RESULTS, RECALL_ALPHA +ALL_COLLECTIONS = ["codebase", "pull-requests", "slack", "wikis", "default"] + + def search_docs( query: str, max_results: int = RECALL_MAX_RESULTS, - scope: str = None, + collections: list = None, graph_context: bool = True, recency_bias: float = 0.2, ): """ PRIMARY VERIFIED PATH - build and test your backend on this function first. - Calls /query. scope (collection) is optional - omit to - search all collections automatically. Response contains: - "chunks" - iterate this array; read chunk_content from each item for LLM context - "sources" - deduplicated source list; use for citations + Calls /query. collections scopes the search; omit to pass + ALL_COLLECTIONS. Response data contains: + "chunks" - iterate this array; read content from each item + "llm_prompt" - prompt-ready markdown block; pass to the LLM as-is """ kwargs = { "database": DATABASE_ID, + "collections": collections or ALL_COLLECTIONS, "query": query, "max_results": max_results, "mode": "thinking", @@ -928,8 +945,6 @@ def search_docs( "alpha": RECALL_ALPHA, "recency_bias": recency_bias, } - if scope: - kwargs["collection"] = scope return client.query(**kwargs) ``` @@ -938,7 +953,7 @@ def search_docs( ### backend/search.py -`recall_context()` calls `search_docs()` and returns the raw HydraDB payload. `build_context_block()` reads the `chunks` array, extracts each `chunk_content` field, and assembles them into a single formatted string that is passed to the LLM. The `sources` array from the payload is used separately by `app.py` for citation output. +`recall_context()` calls `search_docs()` and returns the raw HydraDB payload. `build_context_block()` prefers the prompt-ready `llm_prompt` from the response - a markdown block with citation labels - and falls back to joining `content` from each chunk when it is empty. The `context_id` values from `chunks` are used separately by `app.py` for citation output. ```python from backend.hydra_client import search_docs @@ -947,13 +962,12 @@ from backend.hydra_client import search_docs def recall_context(query: str, max_results: int = 10): """ Call search_docs() with sensible defaults for the first backend. - scope=None means HydraDB searches all collections automatically. - Returns SDK object with .chunks and .sources attributes. + Returns SDK object; data has chunks, graph, forceful_relations, llm_prompt. """ return search_docs( query=query, max_results=max_results, - scope=None, # no collection needed - HydraDB handles scope + collections=None, # defaults to ALL_COLLECTIONS graph_context=True, recency_bias=0.2, ) @@ -961,37 +975,35 @@ def recall_context(query: str, max_results: int = 10): def build_context_block(recall_payload) -> str: """ - Extract chunk_content from each chunk and assemble the LLM context string. + Assemble the LLM context string from a /query result. - chunks - the main array; iterate this and read chunk_content from each item. - sources - the deduplicated source list; used for citations in app.py. - - chunk_content is the canonical field to pass into the LLM. - Always iterate chunks[], never a "results" key - the correct key is "chunks". + data.llm_prompt is the recommended path: a prompt-ready markdown + block with citation labels ([1], [R1], [P1]). Fall back to joining + chunk.content when llm_prompt is empty. Iterate chunks[], never a + "results" key - the correct key is "chunks". """ - chunks = recall_payload.data.chunks or [] - sources = recall_payload.data.sources or [] - - context_parts = [] - for chunk in chunks: - text = (chunk.chunk_content or "").strip() - if text: - context_parts.append(text) - - context_text = "\n\n".join(context_parts) - - source_lines = [] - for source in sources: - title = context.title or "Untitled" - id = context.id or "" - source_lines.append(f"- {title} ({id})") + data = recall_payload.data + + if data.llm_prompt: + context_text = data.llm_prompt + else: + context_text = "\n\n".join( + (c.content or "").strip() + for c in (data.chunks or []) + if c.content + ) - sources_text = "\n".join(source_lines) + # Citation labels: unique context_ids in score order + seen, source_lines = set(), [] + for c in (data.chunks or []): + if c.context_id and c.context_id not in seen: + seen.add(c.context_id) + source_lines.append(f"- {c.context_id}") - return f"Context:\n{context_text}\n\nSources:\n{sources_text}" + return f"Context:\n{context_text}\n\nSources:\n{chr(10).join(source_lines)}" ``` -> 💡 **Production upgrade: score filtering.** `build_context_block` is intentionally simple for the first backend. For production, add a `relevancy_score` filter to drop low-quality chunks before building the context string - the same pattern used in Phase 1's `build_context()` helper. +> 💡 **Production upgrade: score filtering.** `build_context_block` is intentionally simple for the first backend. For production, add a `score` filter to drop low-quality chunks before building the context string - the same pattern used in Phase 1's `build_context()` helper. --- @@ -1118,7 +1130,11 @@ async def chat(body: dict): async def stream(): # Emit sources first so the client can render citations immediately - sources_list = [{"title": s.title, "id": s.id} for s in (recall_payload.data.sources or [])] + seen, sources_list = set(), [] + for c in (recall_payload.data.chunks or []): + if c.context_id and c.context_id not in seen: + seen.add(c.context_id) + sources_list.append(c.context_id) yield json.dumps({"sources": sources_list}) + "\n" for token in stream_answer(question, context_block): yield json.dumps({"text": token}) + "\n" @@ -1131,7 +1147,7 @@ def ask(body: dict): """ Synchronous JSON endpoint - easier for Postman testing and API consumers that don't stream. Returns a complete response object in one round-trip. - Returns answer, sources, chunks, and graph_context. + Returns answer, sources, chunks, and graph. """ question = body.get("question", "").strip() if not question: @@ -1141,12 +1157,18 @@ def ask(body: dict): context_block = build_context_block(recall_payload) full_answer = "".join(stream_answer(question, context_block)) + seen, sources_list = set(), [] + for c in (recall_payload.data.chunks or []): + if c.context_id and c.context_id not in seen: + seen.add(c.context_id) + sources_list.append(c.context_id) + return { - "answer": full_answer, - "sources": [{"title": s.title, "id": s.id} for s in (recall_payload.data.sources or [])], - "chunks": [{"chunk_content": c.chunk_content, "relevancy_score": c.relevancy_score, - "source_title": c.source_title} for c in (recall_payload.data.chunks or [])], - "graph_context": None, + "answer": full_answer, + "sources": sources_list, + "chunks": [{"content": c.content, "score": c.score, + "context_id": c.context_id} for c in (recall_payload.data.chunks or [])], + "graph": recall_payload.data.graph, } ``` @@ -1156,10 +1178,10 @@ def ask(body: dict): Understanding the exact data path prevents debugging confusion. `build_context_block()` does the following in order: -1. **Read the `chunks` array** from the search payload. `/query` returns `{"chunks": [...], "sources": [...]}` - always iterate `chunks`, never `results`. -2. **Extract `chunk_content`** from each chunk object. This is the raw text of the chunk - a few hundred tokens of the original document. It is the only required field. Skip any chunk where `chunk_content` is absent or empty. -3. **Join all `chunk_content` values** with double newlines to form the context body. -4. **Append the sources list** from `recall_payload.sources` in `- title (id)` format so the model can cite them. +1. **Read `data.llm_prompt`** from the search payload. `/query` returns `data` with `chunks`, `graph`, `forceful_relations` and `llm_prompt`. The prompt is a markdown block with citation labels. +2. **Fall back to the `chunks` array** when `llm_prompt` is empty. Always iterate `chunks`, never `results`. Each chunk's `content` is the raw text - a few hundred tokens of the original item. +3. **Join the text** into the context body. +4. **Append the sources list** built from unique `context_id` values in `chunks`, in `- context_id` format so the model can cite them. 5. **Return the assembled string** to `stream_answer()`. This becomes the user message body inside the OpenAI prompt. The assembled context block that GPT-4o receives looks like this: @@ -1257,10 +1279,10 @@ Detailed implementation coming in the next revision. Everything in Phase 4 is pa ### If Your Retrieval Quality Is Weak -- **Fix your IDs and metadata.** Weak retrieval often starts upstream. Use stable IDs, clear titles, accurate timestamps, and useful metadata like `doc_type`, `repo`, and `channel`. -- **Add explicit relations.** If "why" answers feel shallow, it usually means the assistant sees the code but not the documents around it. Add `relations.ids` between code, PRs, Slack, and RFCs. -- **Improve source formatting.** A PR with only a title is weak. A PR with title, description, changed files, reviews, and inline comments is strong. Rich documents produce better chunks. -- **Add relevancy_score filtering.** The production upgrade to `build_context_block` is to drop chunks where `relevancy_score` is below your threshold before assembling the context string. +- **Fix your IDs and attributes.** Weak retrieval often starts upstream. Use stable `context_id`s, clear titles, accurate `happened_at` dates, and useful attributes like `doc_type`, `repo`, and `channel`. +- **Add explicit relations.** If "why" answers feel shallow, it usually means the assistant sees the code but not the documents around it. Add `forceful_relations.context_ids` between code, PRs, Slack, and RFCs. +- **Improve source formatting.** A PR with only a title is weak. A PR with title, description, changed files, reviews, and inline comments is strong. Rich items produce better chunks. +- **Add score filtering.** The production upgrade to `build_context_block` is to drop chunks where `score` is below your threshold before assembling the context string. ### How to Extend Beyond the First Version @@ -1286,19 +1308,19 @@ A good progression: Phase 0 search in the terminal → FastAPI `/ask` in Postman **Cause:** The endpoint path has a typo, extra slash, or missing segment. Also occurs when querying a `collection` that has never had data written to it. -**Fix:** Correct paths: `/context/ingest` (file upload or JSON batch with `app_knowledge`), `/query`, `/context/status`. If using `collection`, confirm at least one batch was successfully uploaded to that collection first. +**Fix:** Correct paths: `/context/ingest` (JSON body with a `context` array, or multipart form with `context` as a JSON string), `/query`, `/context/status`. If using `collection`, confirm at least one batch was successfully uploaded to that collection first. -### 400 - Provide at least one of: documents or app_knowledge +### 400 - context is required / unknown field -**Cause:** For file upload: the field name is wrong (`file` instead of `documents`), or `Content-Type: application/json` was manually set (which breaks multipart). For JSON batch: the array is empty or objects are missing required fields. +**Cause:** The `context` array is missing or empty, an item has neither `text` nor `conversation` (or has both), or the payload includes a field the API does not know - decoding is strict. -**Fix:** For file upload: use `files={"documents": (filename, f, "text/markdown")}` and `data={"database": DATABASE_ID}`. Do NOT set `Content-Type` manually. For JSON batch: confirm each item has `id`, `type`, and `content.text`. +**Fix:** Send `{"database", "collection", "context": [{...}]}`. Each item needs `text` or `conversation`. Valid optional fields: `context_id`, `title`, `attributes`, `custom_attributes`, `happened_at`, `user_name`, `enrich`, `instructions`, `upsert`, `forceful_relations`, `acl`. There are no `documents`, `app_knowledge`, `memories`, `relations`, or `type` fields. ### Empty Search - chunks: [] -**Cause:** Indexing is still in progress, or `relevancy_score` filtering in `build_context_block` is too aggressive, or the query does not semantically match any ingested content. +**Cause:** Indexing is still in progress, or `score` filtering in `build_context_block` is too aggressive, or the query does not semantically match any ingested content. -**Fix:** Step 1 - Run `verify_file()` and confirm `"indexing_status": "completed"` in the `statuses` array. Step 2 - Run `phase0/search.py` directly and print the raw response - check that the `chunks` key is present and non-empty before any filtering. Step 3 - Check the `relevancy_score` values on returned chunks. +**Fix:** Step 1 - Run `verify_file()` and confirm `"indexing_status": "completed"` in the `statuses` array. Step 2 - Run `phase0/search.py` directly and print the raw response - check that the `chunks` key is present and non-empty before any filtering. Step 3 - Check the `score` values on returned chunks. ### 429 - OpenAI insufficient_quota @@ -1321,14 +1343,14 @@ def stream_answer(question: str, context: str): ## Production Notes -- **Batch size and rate limits.** The JSON batch upload endpoint accepts a maximum of **20 source objects per request**. Always sleep 1 second between batches. For large repos (1,000+ files), expect ingestion to take 10–30 minutes total. +- **Batch size and limits.** `/context/ingest` accepts a maximum of **100 items per request**, 1 MiB of text per item and 8 MiB per request. For large repos (1,000+ files), expect ingestion to take 10-30 minutes total. - **Indexing delays.** Indexing is async. Never rely on a fixed sleep; always poll [`/context/status`](/api-reference/v2/endpoint/source-status) until the `statuses` array shows `"indexing_status": "completed"`. -- **Consistency and upserts.** HydraDB upserts by `id` - re-uploading replaces the existing document. There is a brief window where search may return stale chunks. Run verify on new IDs before marking a deployment complete. -- **LLM context window.** The basic `build_context_block` assembles all chunk text. For production, add a `relevancy_score` filter and a character cap (e.g. 12,000 chars) to prevent exceeding GPT-4o's context window. -- **Graph edge consistency.** Graph edges only form if both sides exist and are indexed. Always ingest source files first, then PRs. Re-ingest affected PRs if you add new files after initial ingestion. +- **Consistency and upserts.** Set `upsert: true` and a stable `context_id` - re-ingesting replaces the existing item. There is a brief window where search may return stale chunks. Run verify on new IDs before marking a deployment complete. +- **LLM context window.** The basic `build_context_block` passes `llm_prompt` through as-is. For production, add a `score` filter and a character cap (e.g. 12,000 chars) to prevent exceeding GPT-4o's context window. +- **Graph edge consistency.** Forceful edges only form if both sides exist and are indexed. Always ingest source files first, then PRs. Re-ingest affected PRs if you add new files after initial ingestion. - **API key security.** The HydraDB API key grants full access to all database data. Never commit it to git. Use environment secrets in production. Rotate immediately if you suspect exposure. -- **OpenAI token costs.** Each question sends context + system prompt + question to GPT-4o. For a 50-engineer team asking 200 questions/day, budget $2–4/day. Use `gpt-4o-mini` for factual lookups to reduce cost. -- **Search quality monitoring.** Log the `relevancy_score` value of each chunk included in a response. If median scores are falling over time, new content may be diluting the index - re-ingest with better metadata and collections tagging. Also confirm the `chunks` array is non-empty before sending context to GPT-4o. +- **OpenAI token costs.** Each question sends context + system prompt + question to GPT-4o. For a 50-engineer team asking 200 questions/day, budget $2-4/day. Use `gpt-4o-mini` for factual lookups to reduce cost. +- **Search quality monitoring.** Log the `score` value of each chunk included in a response. If median scores are falling over time, new content may be diluting the index - re-ingest with better attributes and collections tagging. Also confirm the `chunks` array is non-empty before sending context to GPT-4o. --- @@ -1340,101 +1362,79 @@ All endpoints used in this cookbook. Base URL: `https://api.hydradb.com`. Header **POST** `/databases` - Async, poll `/databases/status` before ingesting +Request: + ```json -Request: { "database": "engineering-docs" } +{ "database": "engineering-docs" } +``` Response: + +```json { "database": "engineering-docs", "status": "accepted", "message": "Database accepted. Poll /databases/status for readiness." } - -GET /databases/status?database=engineering-docs -→ { "status": "ready" } ``` -### Upload File - Beginner Path +`GET /databases/status?database=engineering-docs` → `{ "status": "ready" }` -**POST** `/context/ingest` - Multipart form-data · field name `"files"` · database as form field +### Ingest Context -```python -# Correct multipart file upload -client.context.ingest( - database=DATABASE_ID, - documents=[(filename, f, "text/markdown")], -) - -# Response: -{ - "results": [ - { - "id": "", - "filename": "auth_middleware.md", - "status": "accepted", - "error": null - } - ] -} -``` - -### Upload Sources - JSON Body (Advanced) - -**POST** `/context/ingest` - Max 20 items · body field `app_knowledge` (JSON string) · supports relations +**POST** `/context/ingest` - Up to 100 items · `context` is the item array (JSON string via the SDK) · `forceful_relations` for explicit edges ```python import json client.context.ingest( database="engineering-docs", - app_knowledge=json.dumps([{ - "id": "myrepo/auth/middleware.py", - "title": "auth/middleware.py", - "type": "document", - "timestamp": "2025-11-14T10:22:00Z", - "content": {"text": "full content here"}, - "collections": ["codebase", "myrepo"], - "relations": {"ids": ["pr-42"]}, - "metadata": {"doc_type": "source_file", "repo": "myrepo"}, + collection="codebase", + upsert=True, + context=json.dumps([{ + "context_id": "myrepo/auth/middleware.py", + "title": "auth/middleware.py", + "text": "full content here", + "happened_at": "2025-11-14", + "attributes": {"doc_type": "source_file", "repo": "myrepo"}, + "forceful_relations": {"context_ids": ["pr-42"]}, }]), ) -# Response: { "ids": ["myrepo/auth/middleware.py"] } +# Response: { "results": [{ "id": "myrepo/auth/middleware.py", "status": "accepted" }] } ``` ### Verify Indexing **GET** `/context/status` - Returns `statuses[]` array · poll until `"completed"` -```jsonc -// Required: ?ids=&database=engineering-docs +Required query params: `?ids=&database=engineering-docs` -// Response: +```json { "statuses": [ { "id": "", "indexing_status": "completed" } ] } -// indexingStatus: "processing" | "completed" | "errored" ``` +`indexing_status` is one of `processing`, `completed`, `errored`. + ### Search - Validated Minimal Request -**POST** `/query` - Primary verified path · `collection` optional · returns `chunks[]` and `sources[]` +**POST** `/query` - Primary verified path · `data` returns `chunks`, `graph`, `forceful_relations`, `llm_prompt` -```jsonc +```json { "database": "engineering-docs", + "collection": "codebase", "query": "internal IP auth skip logic", "max_results": 10 } - -// collection is NOT required. HydraDB handles scope internally. -// Response: {"chunks": [...], "sources": [...]} -// Read chunk_content from each item in chunks[] for LLM context. -// relevancy_score on each chunk indicates ranked relevance. ``` +Read `content` from each item in `chunks[]` for LLM context, or pass `data.llm_prompt` to your LLM as-is. `score` on each chunk indicates ranked relevance. + ### Multi-hop Search **POST** `/query` - Use for multi-hop retrieval, then generate the answer in your app layer @@ -1442,10 +1442,12 @@ client.context.ingest( ```json { "database": "engineering-docs", + "collections": ["codebase", "pull-requests", "slack", "wikis"], "query": "Why does auth middleware skip token validation for internal IPs?", "max_results": 15, "mode": "thinking", "graph_context": true, + "follow_forceful_relations": true, "alpha": 0.65, "recency_bias": 0.15 } @@ -1455,7 +1457,7 @@ client.context.ingest( ## Benchmarks -1,200 developer questions across three engineering teams (18–80 engineers, codebases 150k–2.2M lines) compared against naive vector search using identical ingested content. +1,200 developer questions across three engineering teams (18-80 engineers, codebases 150k-2.2M lines) compared against naive vector search using identical ingested content. | Query type | Naive vector search | HydraDB with graph_context | Δ | |---|---|---|---| diff --git a/cookbooks/v2/cookbook-04-build-notion-ai.mdx b/cookbooks/v2/cookbook-04-build-notion-ai.mdx index 151504a4..41bad5ea 100644 --- a/cookbooks/v2/cookbook-04-build-notion-ai.mdx +++ b/cookbooks/v2/cookbook-04-build-notion-ai.mdx @@ -1,16 +1,13 @@ --- title: "Internal IT Support Agent" description: "Ingest your entire workspace - Notion, Confluence, Slack - into HydraDB and build a conversational interface that understands relationships between documents. Answer 'why did we decide X?' using HydraDB's context graph. Every endpoint in this guide is real and copy-paste ready." -noindex: true --- -This page is deprecated: it documents the knowledge and memory API, which unified databases replace. See [Ingest context](/essentials/v2/ingest) and [Query](/essentials/v2/query). - Notion's built-in AI keyword-searches. It returns the document you asked about and stops. It can't tell you **why** a decision was made, who influenced it, or whether it's been superseded by something newer. -HydraDB is different. It doesn't just store vectors - it builds a **living context graph**. Every memory is parsed, enriched, and connected to other memories. When your agent calls `POST /query`, it doesn't just get semantically similar chunks. It gets the most useful context for that exact query - weighted by recency, relevance, relationships, and historical usage patterns. +HydraDB is different. It doesn't just store vectors - it builds a **living context graph**. Every context item is parsed, enriched, and connected to other items. When your agent calls `POST /query`, it doesn't just get semantically similar chunks. It gets the most useful context for that exact query - weighted by recency, relevance, relationships, and historical usage patterns. -> **All code in this cookbook is real.** Base URL is `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). +> **All code in this cookbook is real.** Base URL is `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). SDK snippets use the call shapes in the [SDK reference](/api-reference/v2/sdks) and require an SDK release generated from the current API specification; the cURL and `requests` examples work today. --- @@ -27,7 +24,7 @@ HydraDB is different. It doesn't just store vectors - it builds a **living conte | Source | Install | Environment | | --- | --- | --- | | Notion | `pip install notion-client` | `NOTION_TOKEN` | -| Confluence | `pip install requests` | `CONFLUENCE_URL`, `CONFLUENCE_USER`, `CONFLUENCE_TOKEN` | +| Confluence | `pip install atlassian-python-api beautifulsoup4` | `CONFLUENCE_URL`, `CONFLUENCE_USER`, `CONFLUENCE_TOKEN` | | Slack | `pip install slack_sdk slack_bolt` | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` | ## What You'll Build @@ -35,7 +32,7 @@ HydraDB is different. It doesn't just store vectors - it builds a **living conte By the end of this cookbook, you'll be able to: - Ingest Notion pages, Confluence docs, and Slack threads into a unified HydraDB workspace - Answer "why did we decide X?" by retrieving decision context across linked documents -- Batch-upload documents with verified indexing before any search query +- Batch-upload context items with verified indexing before any search query - Build a conversational interface grounded in your team's actual knowledge --- @@ -45,8 +42,8 @@ By the end of this cookbook, you'll be able to: Before writing code, understand the three primitives you'll use throughout this cookbook: - **Database** - your workspace. All data is isolated per database. Think of it as your "company" in HydraDB. Create one per application. -- **Memory** - any unit of context: a Notion page, a Confluence doc, a Slack thread, a user preference. HydraDB automatically chunks, embeds, and connects memories into a context graph. -- **Search** - the retrieval call your agent makes before acting. HydraDB's search runs a multi-stage pipeline: metadata filtering → graph traversal → semantic retrieval → personalized ranking. +- **Context item** - any unit of context: a Notion page, a Confluence doc, a Slack thread, a user preference. HydraDB automatically chunks, embeds, and connects items into a context graph. +- **Search** - the retrieval call your agent makes before acting. HydraDB's search is a hybrid pipeline: text and semantic retrieval, attribute filtering, graph context and reranking. **LongMemEvals search accuracy: 90%** @@ -75,8 +72,14 @@ Every HydraDB workspace starts with a database. Create one for your knowledge ba ```bash curl -X POST 'https://api.hydradb.com/databases' \ -H "Authorization: Bearer YOUR_API_KEY" \ + -H "API-Version: 2" \ -H "Content-Type: application/json" \ - -d '{"database": "notion-ai-workspace"}' + -d '{ + "database": "notion-ai-workspace", + "database_metadata_schema": [ + { "name": "source_type", "data_type": "VARCHAR" } + ] + }' ``` ### Python @@ -90,24 +93,36 @@ DATABASE_ID = "notion-ai-workspace" client = HydraDB(token=API_KEY) -# Create database - idempotent, safe to re-run -resp = client.databases.create(database=DATABASE_ID) +# Create database - idempotent, safe to re-run. +# `source_type` is declared here so queries can filter on it later. +resp = client.databases.create( + database=DATABASE_ID, + database_metadata_schema=[ + {"name": "source_type", "data_type": "VARCHAR"}, + ], +) print("Database:", resp) + +# Database creation is asynchronous - poll until ready before ingesting +import time +while not client.databases.status(database=DATABASE_ID).data.infra.ready_for_ingestion: + time.sleep(4) +print("Database ready.") ``` > **Collections for teams:** Use `collection` to isolate data by department. Engineering, Sales, HR each get their own namespace within your database - no configuration needed, just pass the ID on upload. --- -## Step 02 - Upload Knowledge Memories +## Step 02 - Upload Context Items HydraDB automatically parses, chunks, embeds, and connects your content into a context graph. You don't manage embeddings or vector indexes. You just upload. ### Notion Connector -Fetch pages from Notion, format them into HydraDB's app source structure, and batch upload. HydraDB builds the context graph automatically - no edge creation needed. +Fetch pages from Notion, format them as context items, and batch upload. HydraDB builds the context graph automatically - no edge creation needed. -> **Batch limit:** Max **20 sources per request**. Wait **1 second between batches** to respect rate limits. +> **Batch limit:** Max **100 items per request**. ```python import json, time @@ -127,14 +142,12 @@ def extract_text(page_id: str) -> str: return "\n\n".join(lines) -def upload_batch(sources: list, collection: str = None) -> list: - """Upload up to 20 sources. Returns list of IDs.""" - if collection: - for s in sources: - s["collection"] = collection +def upload_batch(items: list, collection: str = None) -> list: + """Upload up to 100 context items. Returns list of IDs.""" result = client.context.ingest( database=DATABASE_ID, - app_knowledge=json.dumps(sources), + collection=collection, + context=json.dumps(items), ) return [r.id for r in (result.data.results or []) if r.id] @@ -153,22 +166,21 @@ def ingest_notion_database(database_id: str, collection: str = None) -> list: author = page["created_by"]["id"] batch.append({ - "id": page["id"], - "title": title, - "type": "notion_page", # required - "timestamp": page["last_edited_time"], # required ISO - "content": {"text": text}, - "url": f"https://notion.so/{page['id'].replace('-','')}", - "metadata": { + "context_id": f"notion-{page['id']}", + "title": title, + "text": text, + "happened_at": page["last_edited_time"][:10], + "attributes": {"source_type": "notion_page"}, + "custom_attributes": { "author": author, + "url": f"https://notion.so/{page['id'].replace('-','')}", "tags": ["notion", "knowledge"], }, }) - if len(batch) == 20: + if len(batch) == 100: all_ids += upload_batch(batch, collection) batch = [] - time.sleep(1) # required 1-second interval between batches if batch: all_ids += upload_batch(batch, collection) @@ -177,7 +189,7 @@ def ingest_notion_database(database_id: str, collection: str = None) -> list: ### Confluence Connector -Confluence pages follow the same upload format. Use `type: "confluence"` so HydraDB can distinguish sources during search and apply metadata filtering. +Confluence pages follow the same upload format. Set `source_type` in `attributes` so queries can filter to Confluence content. ```python from atlassian import Confluence @@ -202,21 +214,20 @@ def ingest_space(space_key: str, collection: str = None) -> list: text = BeautifulSoup(html, "html.parser").get_text("\n\n") batch.append({ - "id": page["id"], - "title": page["title"], - "type": "confluence", # required - "timestamp": page["version"]["when"], # required ISO - "content": {"text": text}, - "metadata": { + "context_id": f"confluence-{page['id']}", + "title": page["title"], + "text": text, + "happened_at": page["version"]["when"][:10], + "attributes": {"source_type": "confluence"}, + "custom_attributes": { "author": page["history"]["createdBy"]["accountId"], "tags": ["confluence", space_key.lower()], }, }) - if len(batch) == 20: + if len(batch) == 100: all_ids += upload_batch(batch, collection) batch = [] - time.sleep(1) if batch: all_ids += upload_batch(batch, collection) @@ -236,7 +247,7 @@ def poll_until_indexed(id: str, timeout: int = 120, interval: int = 3): while time.time() < deadline: result = client.context.status( database=DATABASE_ID, - ids=id, + ids=[id], ) items = result.data.statuses or [] status = items[0].indexing_status if items else None @@ -254,65 +265,59 @@ def verify_all(ids: list): --- -## Step 03 - Add User Memories +## Step 03 - Add User Context -Beyond documents, HydraDB stores **user memories** - preferences, habits, and patterns that personalize search per user. Set `infer: true` to let HydraDB extract implicit signals from text. Set `infer: false` to store facts verbatim. +Beyond documents, HydraDB stores per-user context - preferences, habits, and patterns that personalize search per user. Set `enrich: true` (the default) to let HydraDB extract implicit signals from text; set it to `false` to store facts verbatim. -**Endpoint:** `POST /context/ingest` - Add a user memory +**Endpoint:** `POST /context/ingest` - Add user context ### Bash ```bash curl -X POST 'https://api.hydradb.com/context/ingest' \ -H "Authorization: Bearer YOUR_API_KEY" \ + -H "API-Version: 2" \ -H "Content-Type: application/json" \ -d '{ - "memories": [{ - "text": "Alice prefers concise bullet-point answers and always wants source links", - "user_name": "alice", - "infer": true - }], "database": "notion-ai-workspace", "collection": "user-alice", - "upsert": true + "upsert": true, + "context": [{ + "text": "Alice prefers concise bullet-point answers and always wants source links", + "user_name": "alice", + "enrich": true + }] }' ``` ### Python ```python -def add_user_memory( +def add_user_context( user_name: str, preference: str, collection: str = None, - infer: bool = True, + enrich: bool = True, ) -> dict: - """ - Store a user memory/preference in HydraDB. - Uses the SDK-spec body format with the memories[] array wrapper. - """ - payload = { - "memories": [{ + """Store a user preference in HydraDB as a context item.""" + return client.context.ingest( + database=DATABASE_ID, + collection=collection, + upsert=True, + context=json.dumps([{ "text": preference, "user_name": user_name, - "infer": infer, - }], - "database": DATABASE_ID, - "upsert": True, - } - if collection: - payload["collection"] = collection - - return client.context.ingest( - type='memory',**payload) + "enrich": enrich, + }]), + ) # Example usage -add_user_memory( +add_user_context( user_name="alice", preference="Alice prefers concise bullet-point answers and always wants source links", collection="user-alice", - infer=True, + enrich=True, ) ``` @@ -322,7 +327,7 @@ After a few interactions, HydraDB builds a behavioral model per user. Alice's se ## Step 04 - Search Context -This is the call your agent makes before answering any question. `POST /query` runs HydraDB's full multi-stage pipeline and returns ranked, contextually relevant chunks - including graph context showing relationships between entities. +This is the call your agent makes before answering any question. `POST /query` runs HydraDB's retrieval pipeline and returns ranked chunks, graph paths, and a ready-made `llm_prompt`. **Endpoint:** `POST /query` - Retrieve agent context @@ -331,16 +336,16 @@ def recall_context( query: str, collection: str = None, max_results: int = 10, - alpha: float = 0.8, # FIX: single definition, no duplicate + alpha: float = 0.8, recency_bias: float = 0.3, graph_context: bool = True, ) -> dict: """ - Full search - searches knowledge base (documents). - To also retrieve user memories, call /query separately. - collection: scope to a specific workspace or user namespace. - mode: "thinking" enables personalised ranking. - graph_context: true enables cross-document entity linking. + Full search over the workspace collection. + To search a workspace and a user's context in one call, pass + collections=["workspace", "user-alice"] instead of collection. + mode: "thinking" expands and reranks; "fast" is a single pass. + graph_context: true (the default) returns entity paths under data.graph. """ payload = { "database": DATABASE_ID, @@ -348,7 +353,7 @@ def recall_context( "max_results": max_results, "mode": "thinking", "graph_context": graph_context, - "alpha": alpha, # FIX: single key only + "alpha": alpha, "recency_bias": recency_bias, } if collection: @@ -356,8 +361,10 @@ def recall_context( return client.query(**payload) # Response shape: - # data["chunks"] - ranked context chunks with relevancy_score - # data["graph_context"] - entity paths and chunk_relations + # data.chunks - ranked chunks: content, score, context_id + # data.graph - entity paths related to the hits + # data.forceful_relations - caller-declared relations pulled into the result + # data.llm_prompt - prompt-ready markdown block with citations # Example @@ -366,15 +373,15 @@ context = recall_context( collection="workspace", ) for chunk in (context.data.chunks or []): - print(f"[{chunk.relevancy_score or 0:.2f}] {chunk.source_title or ''}") - print((chunk.chunk_content or "")[:200]) + print(f"[{chunk.score or 0:.2f}] {chunk.context_id or ''}") + print((chunk.content or "")[:200]) ``` --- ## Step 05 - Search and Answer Generation -For conversational, AI-generated answers, first retrieve context with `POST /query`. Then pass the returned `chunks` and `sources` into your application-layer LLM prompt. Key parameters: `alpha` (0-1, balance semantic vs keyword bm25), `recency_bias` (0-1, prefer newer content), and `graph_context`. +For conversational, AI-generated answers, first retrieve context with `POST /query`. The response carries `llm_prompt`: a ready-made markdown block with citation labels you can pass to your application-layer LLM as-is. Key parameters: `alpha` (0-1, balance semantic vs keyword bm25), `recency_bias` (0-1, prefer newer content), and `graph_context`. **Endpoint:** `POST /query` - retrieved context for app-layer answer generation @@ -389,7 +396,8 @@ def ask_workspace( ) -> dict: """ Retrieve workspace context for a question. - Returns chunks, sources, and graph_context. Generate the final answer in your app layer. + Returns chunks, graph, forceful_relations and llm_prompt. + Generate the final answer in your app layer. """ payload = { "database": DATABASE_ID, @@ -403,16 +411,14 @@ def ask_workspace( if collection: payload["collection"] = collection if source_filter: - payload["metadata_filters"] = {"source_type": source_filter} + payload["attributes"] = {"source_type": source_filter} return client.query(**payload) def build_context(result) -> str: - return "\n\n".join( - chunk.chunk_content or "" - for chunk in (result.data.chunks or []) - ) + # llm_prompt is the rendered context block, citations included. + return result.data.llm_prompt or "" # Usage examples @@ -466,12 +472,15 @@ def handle_mention(event, client): result = ask_workspace(question) chunks = result.data.chunks or [] - answer = "\n\n".join(c.chunk_content or "" for c in chunks[:3]) or "No results found." - sources = result.data.sources or [] + answer = "\n\n".join(c.content or "" for c in chunks[:3]) or "No results found." - if sources: - links = "\n".join(f"• {s.title or ''}" for s in sources[:3]) - answer += f"\n\n*Sources:*\n{links}" + # Resolve titles for the cited items + ctx_ids = [c.context_id for c in chunks[:3] if c.context_id] + if ctx_ids: + listed = client.context.list(database=DATABASE_ID, ids=ctx_ids) + titles = [s.title for s in (listed.data.sources or []) if s.title] + if titles: + answer += "\n\n*Sources:*\n" + "\n".join(f"• {t}" for t in titles) client.chat_update( channel=event["channel"], @@ -498,34 +507,47 @@ All endpoints used in this cookbook. Base URL: `https://api.hydradb.com`. Header { "database": "notion-ai-workspace" } ``` -### Upload app sources (Notion, Slack, Confluence…) +### Upload context items (Notion, Slack, Confluence…) -**POST** `/context/ingest` - Max 20/call, 1s between batches +**POST** `/context/ingest` - Max 100 items per request ```json -[{ - "id": "page-uuid", - "title": "RFC-041 Database Migration", - "type": "notion_page", // required - "timestamp": "2024-09-01T08:00:00Z", // required ISO - "content": { "text": "We chose Postgres because..." }, - "url": "https://notion.so/...", - "metadata": { - "author": "alice@company.com", - "tags": ["rfc", "database"] - } -}] +{ + "database": "notion-ai-workspace", + "collection": "workspace", + "context": [{ + "context_id": "notion-page-uuid", + "title": "RFC-041 Database Migration", + "text": "We chose Postgres because...", + "happened_at": "2024-09-01", + "attributes": { "source_type": "notion_page" }, + "custom_attributes": { + "author": "alice@company.com", + "url": "https://notion.so/...", + "tags": ["rfc", "database"] + } + }] +} ``` -### Upload a single file (PDF / DOCX) +### Ingest a file (PDF / DOCX) -**POST** `/context/ingest` - Single file with database as form field +There is no file upload on a unified database. Extract the text in your app (for PDFs, `pypdf`; for DOCX, `python-docx`) and send it as `text` items, chunked to stay under the 1 MiB per item limit: -```bash -curl -X POST 'https://api.hydradb.com/context/ingest' \ - -H "Authorization: Bearer YOUR_API_KEY" \ - -F "documents=@/path/to/document.pdf" \ - -F "database=notion-ai-workspace" +```python +from pypdf import PdfReader + +text = "\n".join(page.extract_text() or "" for page in PdfReader("report.pdf").pages) + +client.context.ingest( + database=DATABASE_ID, + collection="workspace", + context=json.dumps([{ + "context_id": "report-pdf", + "title": "report.pdf", + "text": text, + }]), +) ``` ### Verify processing @@ -534,7 +556,7 @@ curl -X POST 'https://api.hydradb.com/context/ingest' \ ### Full search -**POST** `/query` - Searches knowledge base - returns chunks + graph_context +**POST** `/query` - returns `chunks`, `graph`, `forceful_relations` and `llm_prompt` ```json { @@ -551,7 +573,7 @@ curl -X POST 'https://api.hydradb.com/context/ingest' \ ### Search for answer generation -**POST** `/query` - retrieved context for app-layer answer generation +**POST** `/query` - retrieved context for app-layer answer generation; use `data.llm_prompt` as the context block ```json { @@ -562,43 +584,50 @@ curl -X POST 'https://api.hydradb.com/context/ingest' \ "graph_context": true, "alpha": 0.5, "recency_bias": 0.3, - "metadata_filters": { "source_type": "notion_page" } + "attributes": { "source_type": "notion_page" } } ``` -### Add user memory +### Add user context -**POST** `/context/ingest` - memories[] array wrapper - consistent SDK format +**POST** `/context/ingest` - a `context` item with `user_name` set on the item ```json { - "memories": [{ - "text": "Alice prefers bullet-point responses", - "user_name": "alice", - "infer": true - }], "database": "notion-ai-workspace", "collection": "user-alice", - "upsert": true + "upsert": true, + "context": [{ + "text": "Alice prefers bullet-point responses", + "user_name": "alice", + "enrich": true + }] } ``` -### Search user memories +### Query a user's context -**POST** `/query` - user_name key - consistent across all calls +**POST** `/query` - scope to the user's collection, or fan out with `collections` ```json { "database": "notion-ai-workspace", - "collection": "user-alice", - "user_name": "alice", + "collections": ["workspace", "user-alice"], "query": "How should I format answers for this user?" } ``` -### Delete memory +### Delete context + +**DELETE** `/context` - remove stale or incorrect items by id -**DELETE** `/context` - Remove stale or incorrect memory data with `type: "memory"` and `request.ids` +```json +{ + "database": "notion-ai-workspace", + "collection": "user-alice", + "ids": ["pref-001"] +} +``` --- @@ -610,7 +639,7 @@ HydraDB leads LongMemEvals with 90% search accuracy. Compared to a naive RAG pip |-----------|-----------|---------|-------| | Factual lookup queries | 81% search | 90% search | +11% | | "Why did we…" decision queries | 34% search | 79% search | +132% | -| Stale doc surface rate | 41% of results | 7% of results | −83% | +| Stale doc surface rate | 41% of results | 7% of results | -83% | | P95 query latency | 220ms | under 200 ms | Sub-second | > **Benchmark methodology.** Figures are based on internal HydraDB testing. For the formal benchmark paper see [research.hydradb.com/hydradb.pdf](https://research.hydradb.com/hydradb.pdf). Results will vary by corpus size, content quality, and query distribution. diff --git a/cookbooks/v2/cookbook-10-ai-financial-analyst.mdx b/cookbooks/v2/cookbook-10-ai-financial-analyst.mdx index 7e38b29a..6e799a29 100644 --- a/cookbooks/v2/cookbook-10-ai-financial-analyst.mdx +++ b/cookbooks/v2/cookbook-10-ai-financial-analyst.mdx @@ -1,11 +1,8 @@ --- -title: "AI Financial Analyst with Memory" -description: "Upload earnings PDFs, internal metrics, and board memos into HydraDB. Ask trend questions across quarters, get temporally-aware answers, and surface the exact clause, figure, or narrative shift - not a generic summary." -noindex: true +title: "AI Financial Analyst" +description: "Ingest earnings filings, internal metrics, and board memos into HydraDB. Ask trend questions across quarters, get temporally-aware answers, and surface the exact clause, figure, or narrative shift - not a generic summary." --- -This page is deprecated: it documents the knowledge and memory API, which unified databases replace. See [Ingest context](/essentials/v2/ingest) and [Query](/essentials/v2/query). - This guide walks you through building a production-grade **AI Financial Analyst** powered by HydraDB. The agent ingests structured and unstructured financial data - earnings call transcripts, PDF filings, internal metric exports, and board memos - and answers questions that require reasoning across time: - _"How did our gross margin trend across the last four quarters?"_ @@ -15,7 +12,7 @@ This guide walks you through building a production-grade **AI Financial Analyst* Standard RAG fails on these because **two earnings calls produce nearly identical embeddings** - they're the same format, the same vocabulary, the same topics. A vector search can't tell Q2 from Q4 without temporal structure. HydraDB's `recency_bias` parameter, timestamp-aware graph, and multi-stage retrieval pipeline solve this structurally. -> **Note**: All API calls in this guide are real and ready to run. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). +> **Note**: All API calls in this guide are real and ready to run. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). SDK snippets use the call shapes in the [SDK reference](/api-reference/v2/sdks) and require an SDK release generated from the current API specification. There is no file upload on a unified database: this cookbook extracts PDF text client-side with [pypdf](https://pypdf.readthedocs.io/) and ingests it as `text` items. --- @@ -25,7 +22,7 @@ Standard RAG fails on these because **two earnings calls produce nearly identica **Required tools**: - HydraDB API key - Python 3.11 or 3.12 (`python --version`) -- `pip install hydradb-sdk` +- `pip install hydradb-sdk pypdf` ## What You'll Build @@ -43,7 +40,7 @@ The structural problem is **temporal ambiguity**. A Q2 2023 earnings call and a HydraDB fixes this through three architectural properties: -1. **Timestamp-aware indexing** - every ingested document carries an ISO 8601 `timestamp` field that HydraDB indexes as a first-class attribute alongside the vector. `recency_bias` uses this to weight results by recency or spread results across time depending on what the query needs. +1. **Timestamp-aware indexing** - every ingested item carries an ISO 8601 `happened_at` field that HydraDB indexes as a first-class temporal attribute alongside the vector. `recency_bias` uses this to weight results by recency or spread results across time depending on what the query needs. 2. **Temporal Knowledge Graph** - entities (companies, executives, metrics, products) are stored as nodes. Each mention of a metric across different documents creates a time-ordered edge sequence on that entity - effectively a versioned history. Querying "revenue trend" traverses these edges in temporal order rather than returning a flat ranked list of chunks. 3. **Multi-query expansion** - in `mode: "thinking"`, HydraDB expands the query into semantically diverse reformulations, then executes all of them in parallel. "How did guidance change between Q2 and Q4?" becomes "Q2 guidance outlook", "Q4 forward guidance revised", "guidance comparison quarterly" - each targeting a different point on the timeline. @@ -52,7 +49,7 @@ HydraDB fixes this through three architectural properties: | Q2 vs Q4 trend question | Returns one call, ignores the other | Retrieves both, timestamps preserved | | "Current" vs "historical" | No distinction | `recency_bias` controls the balance | | Same metric across quarters | Chunks look identical, rank arbitrarily | Graph edges connect metric nodes across time | -| CFO quote attribution | Quote appears; quarter is lost | Source metadata + timestamp surfaced in every chunk | +| CFO quote attribution | Quote appears; quarter is lost | `context_id` + `happened_at` surfaced in every chunk | | Cross-source synthesis (PDF + metrics + memo) | Siloed - no linking across sources | Context graph links by entity across all sources | --- @@ -61,9 +58,9 @@ HydraDB fixes this through three architectural properties: ```mermaid graph TD - A["Earnings PDFs\n(10-K, 10-Q, transcripts)"] -->|SDK upload| B["HydraDB\nIngestion Pipeline"] - C["Internal Metrics\n(CSV / JSON exports)"] -->|SDK upload| B - D["Board Memos\n(PDF / text)"] -->|SDK upload| B + A["Earnings PDFs\n(10-K, 10-Q, transcripts)"] -->|context items| B["HydraDB\nIngestion Pipeline"] + C["Internal Metrics\n(CSV / JSON exports)"] -->|context items| B + D["Board Memos\n(PDF / text)"] -->|context items| B B --> E["Temporal Knowledge Graph\n+ Vector Index"] F["Analyst Profile\n(/context/ingest)"] --> E E -->|"/query\nrecency_bias + graph_context"| G["Context Assembly"] @@ -75,7 +72,7 @@ graph TD - One **database** for the entire financial data corpus. Collections namespace by company or analyst team. - **`recency_bias: 0.7`** for trend questions (surface recent but still gather historical). **`recency_bias: 0.3`** for historical comparison (spread evenly across time). - **`mode: "thinking"`** for all analytical queries - multi-query reranking is essential for financial reasoning. -- **`graph_context: true`** on `/query` gives you `query_paths` - the entity relationship chains that show how a metric's value evolved across documents. +- **`graph_context: true`** on `/query` gives you `data.graph` - entity relationship chains (`path_summary` + `triplets`) that show how a metric's value evolved across documents. --- @@ -85,7 +82,7 @@ One database for the whole financial corpus. Collections isolate by company, fun ```python # setup.py -import os +import os, time from hydra_db import HydraDB API_KEY = os.environ["HYDRA_DB_API_KEY"] @@ -93,98 +90,131 @@ TENANT_ID = "financial-analyst" client = HydraDB(token=API_KEY) -# Create database -result = client.databases.create(database=TENANT_ID) +# Create database - declare the attribute fields used for filters below +result = client.databases.create( + database=TENANT_ID, + database_metadata_schema=[ + {"name": "ticker", "data_type": "VARCHAR"}, + {"name": "doc_type", "data_type": "VARCHAR"}, + {"name": "fiscal_year", "data_type": "INT64"}, + {"name": "fiscal_quarter", "data_type": "INT64"}, + {"name": "period_label", "data_type": "VARCHAR"}, + ], +) print(result) # Output: {'status': 'accepted', 'database': 'financial-analyst', ...} +# Database creation is asynchronous - poll until ready before ingesting +while not client.databases.status(database=TENANT_ID).data.infra.ready_for_ingestion: + time.sleep(4) +print(f"✓ Database '{TENANT_ID}' ready.") + # Collection conventions used throughout this guide: # "earnings-{TICKER}" - earnings calls + SEC filings for one company # "internal-metrics" - internal financial metrics exports # "board-memos" - board meeting memos -# "analyst-{user_id}" - per-analyst preference memory +# "analyst-{user_id}" - per-analyst preference context ``` -> **SDK required for all API calls.** Install: `pip install hydradb-sdk`. Import as `from hydra_db import HydraDB` (the import name differs from the package name). +> **SDK snippets use the call shapes in the [SDK reference](/api-reference/v2/sdks).** They require an SDK release generated from the current API specification. Install: `pip install hydradb-sdk`. Import as `from hydra_db import HydraDB` (the import name differs from the package name). --- -## Step 2 - Upload Financial Documents +## Step 2 - Ingest Financial Documents ### 2.1 Earnings Call Transcripts & SEC Filings (PDF) -Earnings PDFs are the primary context. Tag each with structured metadata - `ticker`, `period`, `doc_type`, `fiscal_year`, `fiscal_quarter` - so you can filter search to a specific company or exact fiscal period before semantic search even runs. +Earnings PDFs are the primary context. There is no file upload on a unified database, so extract the text client-side with `pypdf` and ingest it as `text` items. Tag each with declared attributes - `ticker`, `doc_type`, `fiscal_year`, `fiscal_quarter`, `period_label` - so you can filter search to a specific company or exact fiscal period before semantic search even runs. ```python # ingest/earnings_pdfs.py import json, time, os +from pypdf import PdfReader from hydra_db import HydraDB client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) TENANT_ID = "financial-analyst" +MAX_TEXT_BYTES = 900 * 1024 # stay under the 1 MiB per-item text limit + + +def extract_pdf_text(file_path: str) -> list[str]: + """Extract text from a PDF and split into <=1 MiB parts.""" + reader = PdfReader(file_path) + encoded = "\n".join(page.extract_text() or "" for page in reader.pages).encode("utf-8") + + parts, start = [], 0 + while start < len(encoded): + end = min(start + MAX_TEXT_BYTES, len(encoded)) + # prefer a blank-line boundary; otherwise back off to a UTF-8 + # character boundary (continuation bytes have the form 10xxxxxx) + boundary = encoded.rfind(b"\n\n", start, end) + if boundary > start: + end = boundary + else: + while end > start and end < len(encoded) and (encoded[end] & 0xC0) == 0x80: + end -= 1 + parts.append(encoded[start:end].decode("utf-8")) + start = end + return parts + + def ingest_earnings_pdf( file_path: str, ticker: str, doc_type: str, # "earnings_transcript" | "10K" | "10Q" | "8K" | "annual_report" fiscal_year: int, - fiscal_quarter: int, # 1–4; use 0 for annual filings + fiscal_quarter: int, # 1-4; use 0 for annual filings period_label: str, # e.g. "Q2 2023" - human-readable label for UI period_end_date: str, # ISO 8601, e.g. "2023-06-30T00:00:00Z" -) -> str: +) -> list: """ - Upload a single earnings PDF. Returns the id for verification. - - IMPORTANT: database and collection must appear both as top-level - SDK params AND inside the app_knowledge JSON - AppKnowledgeModel validates both. + Extract an earnings PDF's text and ingest it as context items. + Returns the list of context_ids (one per text part) for verification. """ collection = f"earnings-{ticker.lower()}" + base_id = f"{ticker}-{doc_type}-{fiscal_year}-Q{fiscal_quarter}" + + items = [] + for i, part in enumerate(extract_pdf_text(file_path)): + items.append({ + "context_id": f"{base_id}-part{i+1}" if i else base_id, + "title": f"{ticker} {period_label} {doc_type.replace('_', ' ').title()}", + "happened_at": period_end_date, # drives recency ranking - must be accurate + "text": part, + "attributes": { + "ticker": ticker, + "doc_type": doc_type, + "fiscal_year": fiscal_year, + "fiscal_quarter": fiscal_quarter, + "period_label": period_label, + }, + "custom_attributes": {"source_file": os.path.basename(file_path)}, + }) - app_knowledge = json.dumps([{ - "id": f"{ticker}-{doc_type}-{fiscal_year}-Q{fiscal_quarter}", - "title": f"{ticker} {period_label} {doc_type.replace('_', ' ').title()}", - "type": "pdf", - "timestamp": period_end_date, # drives recency ranking - must be accurate - "database": TENANT_ID, - "collection": collection, - "metadata": { - "ticker": ticker, - "doc_type": doc_type, - "fiscal_year": fiscal_year, - "fiscal_quarter": fiscal_quarter, - "period_label": period_label, - "period_end_date": period_end_date, - } - }]) - - with open(file_path, "rb") as f: - # FIX: SDK expects a tuple of (filename, file_obj, mime_type), not a raw file handle. + ids = [] + for i in range(0, len(items), 100): # max 100 items per request result = client.context.ingest( database=TENANT_ID, collection=collection, - app_knowledge=app_knowledge, - documents=[(os.path.basename(file_path), f, "application/pdf")], + upsert=True, + context=json.dumps(items[i:i+100]), ) + ids += [r.id for r in (result.data.results or [])] - id = result.data.results[0].id if result.data.results else str(result) - print(f"Uploaded {ticker} {period_label} {doc_type} → id: {id}") - return id + print(f"Uploaded {ticker} {period_label} {doc_type} → {len(ids)} item(s)") + return ids def ingest_earnings_batch(filings: list[dict]) -> list[str]: """ - Upload a batch of earnings PDFs. Max 20 per batch, 1s between batches. + Ingest a batch of earnings PDFs. Each item in filings: {file_path, ticker, doc_type, fiscal_year, fiscal_quarter, period_label, period_end_date} """ ids = [] - for i in range(0, len(filings), 20): - batch = filings[i:i+20] - for filing in batch: - fid = ingest_earnings_pdf(**filing) - ids.append(fid) - if i + 20 < len(filings): - time.sleep(1) # rate limit between batches + for filing in filings: + ids += ingest_earnings_pdf(**filing) return ids @@ -229,13 +259,13 @@ filings = [ ] ids = ingest_earnings_batch(filings) # Output: -# Uploaded ACME Q1 2023 earnings_transcript → id: 988293fa-e29 -# Uploaded ACME Q2 2023 earnings_transcript → id: 9d7951c8-160 -# Uploaded ACME Q3 2023 earnings_transcript → id: 2cf6ea6e-5fe -# Uploaded ACME Q4 2023 earnings_transcript → id: edff67d5-237 +# Uploaded ACME Q1 2023 earnings_transcript → 1 item(s) +# Uploaded ACME Q2 2023 earnings_transcript → 1 item(s) +# Uploaded ACME Q3 2023 earnings_transcript → 1 item(s) +# Uploaded ACME Q4 2023 earnings_transcript → 1 item(s) ``` -> **The `timestamp` field is load-bearing.** HydraDB uses `timestamp` to sort and weight results when `recency_bias` is set. If you leave it blank or use the upload date instead of the reporting period end date, every Q2 and Q4 call will look equally "recent" and temporal queries will fail. Always use the fiscal period end date. +> **The `happened_at` field is load-bearing.** HydraDB uses `happened_at` to sort and weight results when `recency_bias` is set. If you leave it blank or use the upload date instead of the reporting period end date, every Q2 and Q4 call will look equally "recent" and temporal queries will fail. Always use the fiscal period end date. ### 2.2 Internal Metrics (CSV / JSON) @@ -273,36 +303,37 @@ def ingest_metrics_series(metrics_by_period: list[dict]) -> list[str]: metrics: dict {revenue_usd: ..., arr_usd: ..., churn_pct: ..., ...} } - Uses /context/ingest (not upload_knowledge) because metrics are - structured facts that benefit from infer:true graph extraction. + Uses /context/ingest with enrich:true so metrics become + structured facts that benefit from graph extraction. """ - memory_ids = [] + item_ids = [] for item in metrics_by_period: text_chunk = format_metrics_as_text(item["metrics"], item["period_label"]) result = client.context.ingest( - type='memory', database=TENANT_ID, collection="internal-metrics", upsert=True, - memories=json.dumps([{ - "text": text_chunk, - "infer": True, # extract entities + build graph connections - "metadata": { + context=json.dumps([{ + "context_id": f"{item['ticker']}-internal_metrics-{item['fiscal_year']}-Q{item['fiscal_quarter']}", + "title": f"{item['ticker']} internal metrics {item['period_label']}", + "happened_at": item["period_end_date"], + "text": text_chunk, + "enrich": True, # extract entities + build graph connections + "attributes": { "ticker": item["ticker"], "doc_type": "internal_metrics", "period_label": item["period_label"], - "period_end_date":item["period_end_date"], "fiscal_year": item["fiscal_year"], "fiscal_quarter": item["fiscal_quarter"], - } + }, }]), ) - memory_ids.append(result.data.results[0].id if result.data.results else "ok") + item_ids.append(result.data.results[0].id if result.data.results else "ok") print(f"Stored metrics: {item['period_label']} ({item['ticker']})") - return memory_ids + return item_ids # ── Example: 4-quarter internal metrics series ─────────────────────────── @@ -407,36 +438,42 @@ def ingest_board_memo( meeting_date: str, # ISO 8601 - date of the board meeting period_label: str, # e.g. "Q3 2023 Board Meeting" memo_type: str, # "board_memo" | "investor_letter" | "management_commentary" -) -> str: + fiscal_year: int = None, + fiscal_quarter: int = None, +) -> list: + """Extract a board memo's text with pypdf and ingest it as context items.""" + from ingest.earnings_pdfs import extract_pdf_text # reuse the PDF extractor collection = "board-memos" + base_id = f"{ticker}-{memo_type}-{meeting_date[:10]}" - app_knowledge = json.dumps([{ - "id": f"{ticker}-{memo_type}-{meeting_date[:10]}", - "title": f"{ticker} {period_label} - {memo_type.replace('_', ' ').title()}", - "type": "pdf", - "timestamp": meeting_date, - "database": TENANT_ID, - "collection": collection, - "metadata": { - "ticker": ticker, - "doc_type": memo_type, - "period_label": period_label, - "meeting_date": meeting_date, - } - }]) - - with open(file_path, "rb") as f: - # FIX: SDK expects a tuple of (filename, file_obj, mime_type), not a raw file handle. - result = client.context.ingest( - database=TENANT_ID, - collection=collection, - app_knowledge=app_knowledge, - documents=[(os.path.basename(file_path), f, "application/pdf")], - ) + attrs = { + "ticker": ticker, + "doc_type": memo_type, + "period_label": period_label, + } + if fiscal_year: attrs["fiscal_year"] = fiscal_year + if fiscal_quarter: attrs["fiscal_quarter"] = fiscal_quarter + + items = [] + for i, part in enumerate(extract_pdf_text(file_path)): + items.append({ + "context_id": f"{base_id}-part{i+1}" if i else base_id, + "title": f"{ticker} {period_label} - {memo_type.replace('_', ' ').title()}", + "happened_at": meeting_date, + "text": part, + "attributes": attrs, + "custom_attributes": {"source_file": os.path.basename(file_path)}, + }) - id = result.data.results[0].id if result.data.results else str(result) - print(f"Uploaded memo: {ticker} {period_label} → {id}") - return id + result = client.context.ingest( + database=TENANT_ID, + collection=collection, + upsert=True, + context=json.dumps(items), + ) + ids = [r.id for r in (result.data.results or [])] + print(f"Uploaded memo: {ticker} {period_label} → {len(ids)} item(s)") + return ids ``` ### 2.4 Verify Indexing Before Going Live @@ -494,13 +531,13 @@ def verify_all_indexed(ids: list[str], poll_interval: int = 3, max_polls: int = verify_all_indexed(ids) ``` -> **Batch limit reminder.** Maximum 20 sources per request. Wait 1 second between batches. Call [`/context/status`](/api-reference/v2/endpoint/source-status) before any production query. +> **Batch limit reminder.** Maximum 100 context items per request. Call [`/context/status`](/api-reference/v2/endpoint/source-status) before any production query. --- -## Step 3 - Store Analyst Memory +## Step 3 - Store Analyst Profiles -Per-analyst memory personalizes search based on the analyst's focus area, preferred companies, and communication style. A macro fund PM cares about different metrics than a sector-specialist equity analyst. +Per-analyst context personalizes search based on the analyst's focus area, preferred companies, and communication style. A macro fund PM cares about different metrics than a sector-specialist equity analyst. ```python # memory/analysts.py @@ -517,16 +554,17 @@ def store_analyst_profile(user_id: str, profile_text: str) -> dict: user_id: their login/email slug - must be consistent across sessions profile_text: natural language - focus, companies covered, preferred depth, style - infer: true - HydraDB extracts signals + builds graph connections automatically + enrich: true - HydraDB extracts signals + builds graph connections automatically """ result = client.context.ingest( - type='memory', database=TENANT_ID, collection=f"analyst-{user_id}", upsert=True, - memories=json.dumps([{ - "text": profile_text, - "infer": True, + context=json.dumps([{ + "context_id": f"profile-{user_id}", + "title": f"Analyst profile - {user_id}", + "text": profile_text, + "enrich": True, }]), ) return result @@ -565,7 +603,7 @@ store_analyst_profile( # Output: Stored analyst profile for 'priya' => ok ``` -> **`infer: true` is the default and should stay on for analyst profiles.** HydraDB extracts signals like `user COVERS ticker:ACME`, `user PREFERS format:quantitative`, `user FOCUS unit_economics`, and builds graph connections automatically. These become structured priors that influence search ranking for every subsequent query from that analyst. +> **`enrich: true` is the default and should stay on for analyst profiles.** HydraDB extracts signals like `user COVERS ticker:ACME`, `user PREFERS format:quantitative`, `user FOCUS unit_economics`, and builds graph connections automatically. These become structured priors that influence search ranking for every subsequent query from that analyst. --- @@ -575,7 +613,7 @@ This is the core of the financial analyst use case. Four distinct query patterns ### 4.1 Point-in-Time: "What happened in Q2?" -Use high `recency_bias` to surface the most relevant recent documents. For point-in-time questions, also use `metadata_filters` to scope to the exact quarter. +Use high `recency_bias` to surface the most relevant recent documents. For point-in-time questions, also use `attributes` filters to scope to the exact quarter. ```python # query/point_in_time.py @@ -594,20 +632,19 @@ def query_point_in_time( ) -> dict: """ Retrieve context about a specific quarter. - metadata_filters narrows to exact period BEFORE semantic search runs. + attributes narrows to exact period BEFORE semantic search runs. recency_bias: 0.7 - prefer the targeted period but allow adjacent context. mode: "thinking" - multi-query reranking, personalised search. """ return client.query( database=TENANT_ID, - collection=f"analyst-{user_id}", query=question, max_results=12, graph_context=True, mode="thinking", alpha=0.5, # balanced keyword + semantic recency_bias=0.7, - metadata_filters={ + attributes={ "ticker": ticker, "fiscal_year": fiscal_year, "fiscal_quarter": fiscal_quarter, @@ -615,6 +652,14 @@ def query_point_in_time( ) +def period_of(chunk) -> str: + """Derive "Q2 2023" from a context_id ending in -{YYYY}-Q{N}.""" + parts = (chunk.context_id or "").split("-") + q = next((p for p in reversed(parts) if p.startswith("Q")), "?") + yr = next((p for p in reversed(parts) if p.isdigit() and len(p) == 4), "????") + return f"{q} {yr}" + + # Usage result = query_point_in_time( question="What did management say about gross margin in Q2 2023?", @@ -624,8 +669,8 @@ result = query_point_in_time( fiscal_quarter=2, ) for chunk in (result.data.chunks or [])[:3]: - print(f"[{chunk.relevancy_score or 0:.2f}] {chunk.source_title or ''}") - print((chunk.chunk_content or "")[:240]) + print(f"[{chunk.score or 0:.2f}] {period_of(chunk)} ({chunk.context_id})") + print((chunk.content or "")[:240]) # Output: # [0.75] Financial Metrics -- Q2 2023 # revenue_usd: 9100000 arr_usd: 37200000 gross_margin_pct: 72.8 @@ -642,6 +687,7 @@ For trend questions, **remove the quarter filter** and lower `recency_bias` so H import os, uuid from openai import OpenAI from hydra_db import HydraDB +from query.point_in_time import period_of TENANT_ID = "financial-analyst" openai_client = OpenAI() @@ -658,7 +704,7 @@ def query_trend( Answer trend questions that span multiple quarters. recency_bias: 0.3 - spread across the timeline, don't cluster recent results. - graph_context: True - returns query_paths showing how the metric evolved. + graph_context: True - returns data.graph showing how the metric evolved. mode: "thinking" - essential for trend queries; expands into per-quarter sub-queries. No fiscal_quarter filter - we want ALL quarters for this ticker. """ @@ -674,45 +720,44 @@ def query_trend( alpha=0.5, recency_bias=0.3, # LOW - surface older documents too graph_context=True, # get temporal entity paths - metadata_filters=filters, + attributes=filters, ) - chunks = search.data.chunks or [] - graph_ctx = search.data.graph_context - query_paths = graph_ctx.query_paths if graph_ctx else [] + chunks = search.data.chunks or [] + graph = search.data.graph or [] if not chunks: - return "No relevant context found - verify documents are uploaded and indexed." + return "No relevant context found - verify documents are ingested and indexed." - # Sort chunks by timestamp so LLM sees them in chronological order - chunks_sorted = sorted( - chunks, - key=lambda c: c.source_upload_time or "", - ) + # Sort chunks chronologically using the -{YYYY}-Q{N} tail of each context_id + def period_key(c) -> str: + parts = (c.context_id or "").split("-") + q = next((p for p in reversed(parts) if p.startswith("Q")), "Q9") + yr = next((p for p in reversed(parts) if p.isdigit() and len(p) == 4), "9999") + return f"{yr}-{q}" + + chunks_sorted = sorted(chunks, key=period_key) # Build context with explicit period labels and source attribution context_parts = [] for c in chunks_sorted: - meta = c.additional_metadata or {} - period = meta.get("period_label", "unknown period") - doc_type = meta.get("doc_type", "unknown source") - score = c.relevancy_score or 0 context_parts.append( - f"[{period} | {doc_type} | relevance:{score:.2f}]\n{c.chunk_content or ''}" + f"[{period_of(c)} | relevance:{(c.score or 0):.2f}]\n{c.content or ''}" ) # Append graph paths - temporal entity relationships - for path in query_paths[:6]: - context_parts.append(f"[Graph path - temporal]: {str(path)}") + for entry in graph[:6]: + context_parts.append(f"[Graph path - temporal]: {entry.path_summary}") # Retrieve analyst profile for answer personalization analyst_prefs = client.query( - type="memory", database=TENANT_ID, collection=f"analyst-{user_id}", mode="thinking", query="focus area metrics preferences output format", ) + profile_text = (analyst_prefs.data.chunks[0].content + if analyst_prefs.data.chunks else "No profile stored.") context_text = "\n\n".join(context_parts) @@ -734,7 +779,7 @@ def query_trend( { "role": "user", "content": ( - f"Analyst profile: {analyst_prefs}\n\n" + f"Analyst profile: {profile_text}\n\n" f"Question: {question}\n\n" f"Context (chronological, from HydraDB):\n{context_text}" ) @@ -798,16 +843,17 @@ def cross_source_reconciliation( Uses three separate search calls, one per collection, then merges context. """ - def search(collection: str, doc_type_filter: str) -> list[dict]: + def search(collection: str, doc_type_filter: str) -> list: resp = client.query( database=TENANT_ID, + collection=collection, query=f"{ticker} {period_label} financial performance", max_results=8, mode="thinking", alpha=0.5, recency_bias=0.7, graph_context=True, - metadata_filters={ + attributes={ "ticker": ticker, "fiscal_year": fiscal_year, "fiscal_quarter": fiscal_quarter, @@ -824,7 +870,7 @@ def cross_source_reconciliation( if not chunks: return f"[{label}]: No data found for this period." return f"[{label}]:\n" + "\n---\n".join( - c.chunk_content or "" for c in chunks + c.content or "" for c in chunks ) context_text = "\n\n".join([ @@ -905,6 +951,7 @@ Track how management's language around a specific topic (e.g. guidance, macro ri import os from openai import OpenAI from hydra_db import HydraDB +from query.point_in_time import period_of TENANT_ID = "financial-analyst" openai_client = OpenAI() @@ -931,7 +978,7 @@ def track_narrative_shift( alpha=0.3, # lean keyword for topic specificity recency_bias=0.2, # very even - want full timeline graph_context=False, # narrative question; graph context less useful here - metadata_filters={ + attributes={ "ticker": ticker, "doc_type": "earnings_transcript", }, @@ -939,14 +986,14 @@ def track_narrative_shift( chunks = sorted( search.data.chunks or [], - key=lambda c: (c.additional_metadata or {}).get("period_end_date", ""), + key=lambda c: c.context_id or "", # ids end in -{YYYY}-Q{N} ) if not chunks: return f"No earnings transcripts found for {ticker}. Verify ingestion." context_text = "\n\n".join( - f"[{c['additional_metadata'].get('period_label', '?')}]\n{c['chunk_content']}" + f"[{period_of(c)}]\n{c.content or ''}" for c in chunks ) @@ -1005,7 +1052,7 @@ print(arc) ## Step 5 - Analyst Search Interface -For analyst chat interfaces, use `POST /query` to retrieve chunks, sources, and graph context. Generate the final answer in your application layer with your LLM provider so citations, formatting, and conversation memory stay under your control. +For analyst chat interfaces, use `POST /query` to retrieve chunks, graph context, and the ready-made `llm_prompt`. Generate the final answer in your application layer with your LLM provider so citations, formatting, and conversation history stay under your control. ```python # query/financial_recall.py @@ -1031,9 +1078,10 @@ def financial_recall( """ Retrieve financial context for an analyst question. - Returns: {"chunks": [...], "sources": [...], "graph_context": {...}, "session_id": str} + Returns: {"data": , "session_id": str} - Your app should pass chunks and graph_context to an LLM to generate the final answer. + Your app should pass data.llm_prompt (or data.chunks) to an LLM to generate + the final answer. """ if user_id not in analyst_sessions: analyst_sessions[user_id] = str(uuid.uuid4()) @@ -1045,8 +1093,7 @@ def financial_recall( if fiscal_quarter: filters["fiscal_quarter"] = fiscal_quarter payload: dict = { - "database": TENANT_ID, - "collection": f"analyst-{user_id}", + "database": TENANT_ID, "query": question, "max_results": 15, "graph_context": True, @@ -1055,17 +1102,20 @@ def financial_recall( "recency_bias": recency_bias, } if filters: - payload["metadata_filters"] = filters + payload["attributes"] = filters result = client.query(**payload) - result["session_id"] = analyst_sessions[user_id] - return result + return {"data": result.data, "session_id": analyst_sessions[user_id]} def context_for_llm(result) -> str: + # data.llm_prompt is a ready-made markdown block with citation labels; + # fall back to raw chunk content if it is empty. + if result["data"].llm_prompt: + return result["data"].llm_prompt return "\n\n".join( - chunk.chunk_content or "" - for chunk in (result.data.chunks or []) + chunk.content or "" + for chunk in (result["data"].chunks or []) ) @@ -1090,7 +1140,7 @@ r2 = financial_recall( fiscal_year=2023, recency_bias=0.3, # spread across all quarters ) -print(f"Chunks: {len(r2.data.chunks or [])}") +print(f"Chunks: {len(r2['data'].chunks or [])}") # Cross-doc question - no source filter, let HydraDB find relevant sources r3 = financial_recall( @@ -1120,7 +1170,7 @@ print(context_for_llm(r4)[:1000]) ## Step 6 - Automated Quarterly Briefing Agent -Run this agent after each earnings release. It assembles a full briefing - performance summary, trend table, guidance revision, and narrative shift - and saves it back to HydraDB as a memory for the analyst's next session. +Run this agent after each earnings release. It assembles a full briefing - performance summary, trend table, guidance revision, and narrative shift - and saves it back to HydraDB for the analyst's next session. ```python # agents/briefing.py @@ -1162,7 +1212,7 @@ def generate_quarterly_briefing( 2. Search prior quarters for trend context 3. Cross-source reconciliation 4. Synthesize into a structured briefing - 5. Store the briefing back into HydraDB as analyst memory + 5. Store the briefing back into HydraDB as analyst context """ run_id = str(uuid.uuid4())[:8] print(f"\n=== Briefing: {ticker} {period_label} [run:{run_id}] ===") @@ -1177,7 +1227,7 @@ def generate_quarterly_briefing( alpha=0.5, recency_bias=0.8, graph_context=True, - metadata_filters={ + attributes={ "ticker": ticker, "fiscal_year": fiscal_year, "fiscal_quarter": fiscal_quarter, @@ -1207,19 +1257,19 @@ def generate_quarterly_briefing( # ── 4. Synthesize briefing ──────────────────────────────────────────── print("[4/4] Synthesizing briefing...") current_context = "\n\n".join( - f"[{c.get('additional_metadata', {}).get('period_label','?')} | " - f"{c.get('additional_metadata', {}).get('doc_type','?')}]\n{c['chunk_content']}" + f"[{c.context_id}]\n{c.content or ''}" for c in current_chunks ) # Search analyst profile analyst_prefs = client.query( - type="memory", database=TENANT_ID, collection=f"analyst-{analyst_user_id}", mode="thinking", query="coverage focus metrics preferences format", ) + profile_text = (analyst_prefs.data.chunks[0].content + if analyst_prefs.data.chunks else "No profile stored.") resp = openai_client.chat.completions.create( model="gpt-4o", @@ -1244,7 +1294,7 @@ def generate_quarterly_briefing( { "role": "user", "content": ( - f"Analyst profile: {analyst_prefs}\n\n" + f"Analyst profile: {profile_text}\n\n" f"Ticker: {ticker} | Period: {period_label}\n\n" f"--- Current Quarter Data ---\n{current_context}\n\n" f"--- Trend Analysis ---\n{trend_answer}\n\n" @@ -1258,13 +1308,14 @@ def generate_quarterly_briefing( # ── 5. Store briefing back to HydraDB ──────────────────────────────── store_resp = client.context.ingest( - type='memory', database=TENANT_ID, collection=f"analyst-{analyst_user_id}", upsert=True, - memories=json.dumps([{ - "text": f"QUARTERLY BRIEFING [{ticker}] [{period_label}] [run:{run_id}]:\n{briefing_text}", - "infer": False, # store verbatim - this is the canonical output + context=json.dumps([{ + "context_id": f"briefing-{ticker}-{fiscal_year}-Q{fiscal_quarter}-{run_id}", + "title": f"Quarterly briefing {ticker} {period_label} [run:{run_id}]", + "text": f"QUARTERLY BRIEFING [{ticker}] [{period_label}] [run:{run_id}]:\n{briefing_text}", + "enrich": False, # store verbatim - this is the canonical output }]), ) @@ -1387,44 +1438,36 @@ POST /databases { "database": "financial-analyst" } ``` -### Upload Financial Document (SDK required) +### Ingest Financial Document Text ```http POST /context/ingest -Content-Type: multipart/form-data +Content-Type: application/json ``` -```jsonc -// app_knowledge - JSON string of the array below -[{ - "id": "ACME-earnings_transcript-2023-Q2", - "title": "ACME Q2 2023 Earnings Transcript", - "type": "pdf", - "timestamp": "2023-06-30T00:00:00Z", +```json +{ "database": "financial-analyst", - "collection":"earnings-acme", - "metadata": { - "ticker": "ACME", - "doc_type": "earnings_transcript", - "fiscal_year": 2023, - "fiscal_quarter": 2, - "period_label": "Q2 2023", - "period_end_date": "2023-06-30T00:00:00Z" - } -}] + "collection": "earnings-acme", + "upsert": true, + "context": [{ + "context_id": "ACME-earnings_transcript-2023-Q2", + "title": "ACME Q2 2023 Earnings Transcript", + "happened_at": "2023-06-30T00:00:00Z", + "text": "Text extracted client-side from ACME_Q2_2023_transcript.pdf with pypdf...", + "attributes": { + "ticker": "ACME", + "doc_type": "earnings_transcript", + "fiscal_year": 2023, + "fiscal_quarter": 2, + "period_label": "Q2 2023" + }, + "custom_attributes": {"source_file": "ACME_Q2_2023_transcript.pdf"} + }] +} ``` -> Max 20 sources per request. Wait 1 second between batches. - -### Upload PDF via cURL - -```bash -curl -X POST 'https://api.hydradb.com/context/ingest' \ - -H "Authorization: Bearer $HYDRA_DB_API_KEY" \ - -F "documents=@ACME_Q2_2023_transcript.pdf" \ - -F "database=financial-analyst" \ - -F "collection=earnings-acme" -``` +> Max 100 items per request, 1 MiB of text per item. Split long transcripts into parts. ### Verify Indexing @@ -1432,7 +1475,7 @@ curl -X POST 'https://api.hydradb.com/context/ingest' \ GET /context/status?ids=ID&database=financial-analyst ``` -### Store Metrics / Analyst Memory +### Store Metrics / Analyst Profile ```http POST /context/ingest @@ -1440,13 +1483,23 @@ POST /context/ingest ```json { - "memories": [{ - "text": "Q2 2023 Metrics - ACME: revenue_usd: 9100000, arr_usd: 37200000, gross_margin_pct: 72.8, churn_rate_pct: 1.6, cac_usd: 4050", - "infer": true - }], - "database": "financial-analyst", + "database": "financial-analyst", "collection": "internal-metrics", - "upsert": true + "upsert": true, + "context": [{ + "context_id": "ACME-internal_metrics-2023-Q2", + "title": "ACME internal metrics Q2 2023", + "happened_at": "2023-06-30T00:00:00Z", + "text": "Q2 2023 Metrics - ACME: revenue_usd: 9100000, arr_usd: 37200000, gross_margin_pct: 72.8, churn_rate_pct: 1.6, cac_usd: 4050", + "enrich": true, + "attributes": { + "ticker": "ACME", + "doc_type": "internal_metrics", + "fiscal_year": 2023, + "fiscal_quarter": 2, + "period_label": "Q2 2023" + } + }] } ``` @@ -1459,14 +1512,13 @@ POST /query ```json { "database": "financial-analyst", - "collection": "analyst-alice", "query": "What did management say about gross margin in Q2 2023?", "max_results": 12, "graph_context": true, "mode": "thinking", "alpha": 0.5, "recency_bias": 0.7, - "metadata_filters": { + "attributes": { "ticker": "ACME", "fiscal_year": 2023, "fiscal_quarter": 2 @@ -1489,7 +1541,7 @@ POST /query "alpha": 0.5, "recency_bias": 0.3, "graph_context": true, - "metadata_filters": { + "attributes": { "ticker": "ACME", "fiscal_year": 2023 } @@ -1511,7 +1563,7 @@ POST /query "alpha": 0.3, "recency_bias": 0.2, "graph_context": true, - "metadata_filters": { + "attributes": { "ticker": "ACME", "doc_type": "earnings_transcript" } @@ -1537,30 +1589,30 @@ POST /query ```json { - "chunks": [ - { - "chunk_content": "Gross margin for Q2 2023 came in at 72.8%, up 160 basis points...", - "source_title": "ACME Q2 2023 Earnings Transcript", - "relevancy_score": 0.91, - "source_upload_time":"2023-06-30T00:00:00Z", - "additional_metadata": { - "ticker": "ACME", - "doc_type": "earnings_transcript", - "period_label": "Q2 2023", - "fiscal_year": 2023, - "fiscal_quarter": 2 + "data": { + "chunks": [ + { + "chunk_id": "c9f2a1...", + "context_id": "ACME-earnings_transcript-2023-Q2", + "score": 0.91, + "content": "Gross margin for Q2 2023 came in at 72.8%, up 160 basis points..." + } + ], + "graph": [ + { + "origin": "ACME.gross_margin", + "triplets": [ + { + "source": {"entity_id": "...", "name": "ACME gross margin"}, + "relation": {"predicate": "DECREASED_BY", "context": "sales mix headwinds", "relationship_id": "...", "chunk_id": "..."}, + "target": {"entity_id": "...", "name": "Q3 2023"} + } + ], + "path_summary": "ACME gross margin increased in Q2 2023 (efficiency programs), decreased in Q3 2023 (sales mix headwinds)." } - } - ], - "graph_context": { - "query_paths": [ - ["ACME.gross_margin", "INCREASED_BY", "Q2_2023", "CONTEXT: efficiency programs"], - ["ACME.gross_margin", "DECREASED_BY", "Q3_2023", "CONTEXT: sales mix headwinds"] ], - "chunk_relations": [ - {"source": "ACME Q2 2023 transcript", "target": "ACME Q2 2023 board memo", - "relation": "corroborates", "confidence": 0.84} - ] + "forceful_relations": [], + "llm_prompt": "## Context\n\n[1] ACME Q2 2023 Earnings Transcript\nGross margin for Q2 2023 came in at 72.8%..." } } ``` @@ -1571,7 +1623,7 @@ POST /query | Query type | `recency_bias` | Reasoning | |---|---|---| -| What happened in Q4? (point-in-time) | `0.8 – 0.9` | Target the specific most-recent relevant document | +| What happened in Q4? (point-in-time) | `0.8 - 0.9` | Target the specific most-recent relevant document | | How did metric X trend in 2023? | `0.3` | Spread evenly across all 4 quarters | | How has tone on X shifted over the past 2 years? | `0.2` | Even wider spread, oldest documents matter | | Current guidance / most recent statement | `0.9` | Strongly prefer the latest document | @@ -1602,13 +1654,12 @@ Tested across 3 company corpora (4 quarters of earnings transcripts + internal m | Pitfall | Symptom | Fix | |---|---|---| -| Wrong `timestamp` on upload | Q1 and Q4 both surface for "most recent" queries | Use `period_end_date`, not upload date | +| Wrong `happened_at` on ingest | Q1 and Q4 both surface for "most recent" queries | Set `happened_at` to the period end date, not the upload date | | Too high `recency_bias` for trend queries | Only Q4 results returned for "how did X trend?" | Use `recency_bias: 0.3` for trend questions | -| Missing `fiscal_quarter` in metadata | "Q2" filter returns all quarters | Add `fiscal_quarter: 2` to `meta` on upload | -| `file=f` instead of `documents=[f]` in SDK | `TypeError` on `context.ingest()` | SDK expects a **list**: `documents=[f]` not `file=f` | -| Raw `requests` for PDF upload | 422 error on `/context/ingest` | Use the SDK: `pip install hydradb-sdk` | +| Missing `fiscal_quarter` in attributes | "Q2" filter returns all quarters | Add `"fiscal_quarter": 2` to `attributes` on ingest | +| Sending a PDF binary to `/context/ingest` | 400 error, nothing ingested | No file upload on a unified database: extract text with `pypdf` and send it as `text` items | | No `/context/status` polling | Queries return empty results silently | Always poll status before querying | -| `infer: false` on analyst profiles | No personalization applied | Leave `infer: true` (the default) for profiles | +| `enrich: false` on analyst profiles | No personalization applied | Leave `enrich: true` (the default) for profiles | | Empty `chunks` passed to LLM | Confident hallucinations about quarters | Add guard: `if not chunks: return "No data found"` | | Mismatched `collection` on read/write | Empty results despite successful ingestion | Read and write collections must match exactly | | Relative imports in `briefing.py` / `slack_bot.py` | `ModuleNotFoundError` when running as a script | Add `sys.path.insert(0, project_root)` at top of file | diff --git a/cookbooks/v2/customer-support-agent.mdx b/cookbooks/v2/customer-support-agent.mdx index 355c61c0..a2ccded2 100644 --- a/cookbooks/v2/customer-support-agent.mdx +++ b/cookbooks/v2/customer-support-agent.mdx @@ -1,16 +1,13 @@ --- title: "AI Customer Support Agent with Memory" -description: "A support agent that never forgets. Ingest help docs, past ticket resolutions, and every conversation turn into HydraDB. Every response is personalized using per-user memory - the agent knows the customer's plan, their past issues, their preferences, and what already failed before it starts typing." -noindex: true +description: "A support agent that never forgets. Ingest help docs, past ticket resolutions, and every conversation turn into HydraDB. Every response is personalized using per-customer context - the agent knows the customer's plan, their past issues, their preferences, and what already failed before it starts typing." --- -This page is deprecated: it documents the knowledge and memory API, which unified databases replace. See [Ingest context](/essentials/v2/ingest) and [Query](/essentials/v2/query). +This guide walks you through building a **customer support agent with persistent context** powered by HydraDB. Unlike generic chatbots that answer the same way for every customer, this agent knows who it's talking to - their plan, their history, their preferences, and what already didn't work - before it types a single word. -This guide walks you through building a **customer support agent with persistent memory** powered by HydraDB. Unlike generic chatbots that answer the same way for every customer, this agent knows who it's talking to - their plan, their history, their preferences, and what already didn't work - before it types a single word. +> **Note**: All code in this guide is production-ready and uses real HydraDB endpoints. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). SDK snippets use the call shapes in the [SDK reference](/api-reference/v2/sdks) and require an SDK release generated from the current API specification. -> **Note**: All code in this guide is production-ready and uses real HydraDB endpoints. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). - -> **Goal**: Build a support agent that makes two fast HydraDB calls on every ticket - one to retrieve knowledge base context, one to retrieve customer memory - merges both, and passes the result to an LLM for a personalized response. Full round-trip under 400ms. +> **Goal**: Build a support agent that makes two fast HydraDB calls on every ticket - one to retrieve knowledge base context, one to retrieve customer context - merges both, and passes the result to an LLM for a personalized response. Full round-trip under 400ms. --- @@ -26,9 +23,9 @@ This guide walks you through building a **customer support agent with persistent ## What You'll Build By the end of this cookbook, you'll be able to: -- Ingest help docs and past ticket resolutions into a shared HydraDB knowledge base -- Store per-customer memory so the agent knows their plan, history, and preferences before responding -- Make the two-call search pattern - knowledge base + customer memory - on every incoming ticket +- Ingest help docs and past ticket resolutions into a shared HydraDB collection +- Store per-customer context so the agent knows their plan, history, and preferences before responding +- Make the two-call search pattern - knowledge base + customer context - on every incoming ticket - Generate personalized support responses that reference the customer's specific context - Store every conversation turn back into HydraDB to continuously improve future responses @@ -38,10 +35,10 @@ By the end of this cookbook, you'll be able to: Standard AI support chatbots answer the same way for every customer. Ask about a billing issue and you get the generic billing FAQ. The agent has no idea you've asked this three times, that you're on the Enterprise plan, or that the last agent told you it was a known bug being fixed this sprint. -HydraDB fixes this. Every interaction is stored as a memory. Every help doc and past ticket resolution is ingested as knowledge. When a customer opens a new conversation, the agent makes two fast calls to HydraDB: +HydraDB fixes this. Every interaction is stored as context. Every help doc and past ticket resolution is ingested the same way. When a customer opens a new conversation, the agent makes two fast calls to HydraDB: 1. `POST /query` - retrieves knowledge base context: help articles, past ticket resolutions, and linked documents relevant to the customer's message. -2. `POST /query` - retrieves the customer's personal memory: their plan, past issues, inferred preferences, and conversation history. +2. `POST /query` - retrieves the customer's personal context: their plan, past issues, inferred preferences, and conversation history. Both results are merged and passed to the LLM. The result is a support agent that feels like it knows the customer personally. Because it does. @@ -51,9 +48,9 @@ Both results are merged and passed to the LLM. The result is a support agent tha Three HydraDB primitives power this use case: -- **Knowledge memories** - help docs, FAQs, past ticket resolutions. Uploaded once via `client.context.ingest()` and continuously available to every agent handling any customer. HydraDB automatically builds a context graph linking related articles and resolutions. -- **User memories** - per-customer context stored via `POST /context/ingest` with the customer's `user_name`. Each conversation turn, product feedback signal, and inferred preference is stored here. HydraDB's `infer: true` mode automatically extracts implicit preferences from conversation text - "I'd prefer email updates" becomes a stored preference without you parsing it. -- **Two-call search pattern** - when a customer opens a ticket, the agent calls `POST /query` to search the knowledge base and `POST /query` to retrieve personal memory. Results are merged before the LLM call. Use `mode: "thinking"` on both calls to enable personalised ranking. +- **Shared context** - help docs, FAQs, past ticket resolutions. Uploaded once via `client.context.ingest()` into a `knowledge-base` collection and continuously available to every agent handling any customer. HydraDB automatically builds a context graph linking related articles and resolutions. +- **Per-customer context** - stored via `POST /context/ingest` in a `customer-` collection with the customer's `user_name`. Each conversation turn, product feedback signal, and inferred preference is stored here. With `enrich: true` (the default), HydraDB automatically extracts implicit preferences from conversation text - "I'd prefer email updates" becomes a stored preference without you parsing it. +- **Two-call search pattern** - when a customer opens a ticket, the agent calls `POST /query` to search the knowledge base and `POST /query` to retrieve personal context. Results are merged before the LLM call. Use `mode: "thinking"` on both calls to enable reranking. To fan out both scopes in one call instead, pass `collections: ["knowledge-base", "customer-"]`. --- @@ -63,7 +60,7 @@ Three HydraDB primitives power this use case: graph LR A["Customer Message"] -->|"new ticket"| B["Support Agent"] B -->|"POST /query"| C["HydraDB\nKnowledge Base"] - B -->|"POST /query"| D["HydraDB\nCustomer Memory"] + B -->|"POST /query"| D["HydraDB\nCustomer Context"] C -->|"ranked help docs + resolved tickets"| E["Merge Context"] D -->|"preferences + history + plan"| E E -->|"full context"| F["LLM (GPT-4o)"] @@ -75,9 +72,9 @@ graph LR ## Step 1 - Create Database -One database for your support system. Use collections to isolate customer data - each customer gets their own collection, automatically created on their first interaction. This is the B2C pattern from HydraDB's docs. +One database for your support system. Use collections to isolate customer data - each customer gets their own collection, automatically created on their first interaction. -> **SDK required**: Install the official Python SDK - `pip install hydradb-sdk`. The ingestion endpoint (`upload_knowledge`) requires the SDK; raw `requests` with `json=` will return a 422. Note: the import name differs from the package name. +> **SDK required**: Install the official Python SDK - `pip install hydradb-sdk`. Note: the import name differs from the package name. ```python Python SDK @@ -90,12 +87,24 @@ TENANT_ID = "customer-support" # SDK client client = HydraDB(token=API_KEY) -# Create the shared database -client.databases.create(database=TENANT_ID) +# Create the shared database, declaring the fields used to tag content +client.databases.create( + database=TENANT_ID, + database_metadata_schema=[ + {"name": "doc_type", "data_type": "VARCHAR"}, + {"name": "category", "data_type": "VARCHAR"}, + {"name": "source_type", "data_type": "VARCHAR"}, + ], +) + +# Database creation is asynchronous - poll until ready before ingesting +import time +while not client.databases.status(database=TENANT_ID).data.infra.ready_for_ingestion: + time.sleep(4) # B2C pattern: each customer gets their own collection = their user_name. # Collections are created automatically on first write - no setup needed. -def customer_sub_tenant(customer_id: str) -> str: +def customer_collection(customer_id: str) -> str: return f"customer-{customer_id}" # Shared collection for knowledge base (visible to all agents, all customers) @@ -108,15 +117,27 @@ import { HydraDBClient } from "@hydradb/sdk"; const API_KEY = process.env.HYDRA_DB_API_KEY!; const TENANT_ID = "customer-support"; -// SDK client — HydraDBClient handles the base URL internally +// SDK client - HydraDBClient handles the base URL internally const client = new HydraDBClient({ token: API_KEY }); -// Create the shared database -await client.databases.create({ database: TENANT_ID }); +// Create the shared database, declaring the fields used to tag content +await client.databases.create({ + database: TENANT_ID, + databaseMetadataSchema: [ + { name: "doc_type", data_type: "VARCHAR" }, + { name: "category", data_type: "VARCHAR" }, + { name: "source_type", data_type: "VARCHAR" }, + ], +}); + +// Database creation is asynchronous - poll until ready before ingesting +while (!(await client.databases.status({ database: TENANT_ID })).data?.infra?.readyForIngestion) { + await new Promise((resolve) => setTimeout(resolve, 4000)); +} // B2C pattern: each customer gets their own collection = their user_name. // Collections are created automatically on first write - no setup needed. -function customerSubTenant(customerId: string): string { +function customerCollection(customerId: string): string { return `customer-${customerId}`; } @@ -136,11 +157,9 @@ Output: Upload your help docs, FAQs, and past ticket resolutions into a shared `knowledge-base` collection. HydraDB builds a context graph connecting related articles automatically - a question about "billing" will surface linked articles about "invoices", "payment methods", and "plan upgrades" even if the customer didn't mention those words. -> **Batch limit**: Max 20 sources per request. Wait 1 second between batches. - -> **Important - database placement**: `database` and `collection` must appear in **two places**: as top-level SDK parameters AND inside each item in `app_knowledge`. The `AppKnowledgeModel` validates both independently. Omitting either location returns a 400 error. +> **Batch limit**: Up to 100 items per ingest request. -> **app_knowledge format**: The SDK parameter `app_knowledge` takes a **JSON string** - use `json.dumps(batch)`, not a Python list directly. +> **context format**: The SDK parameter `context` takes a **JSON string** - use `json.dumps(batch)`, not a Python list directly. ### Help Docs & FAQs @@ -153,31 +172,30 @@ def ingest_help_docs(articles: list) -> list: """ articles: list of dicts - {id, title, content, category, url, updated_at} category: "billing" | "onboarding" | "technical" | "account" | "general" - updated_at: ISO 8601 - drives recency ranking + updated_at: ISO 8601 or YYYY-MM-DD - drives recency ranking """ batch, all_ids = [], [] for article in articles: batch.append({ - "id": article["id"], - "database": TENANT_ID, # required inside each item - "collection": KB_SUB_TENANT, # required inside each item - "title": article["title"], - "type": "confluence", - "timestamp": article["updated_at"], - "content": {"text": article["content"]}, - "url": article.get("url", ""), - "metadata": { - "doc_type": "help_article", - "category": article["category"], - "tags": ["knowledge-base", article["category"]], + "context_id": article["id"], + "title": article["title"], + "text": article["content"], + "happened_at": article["updated_at"][:10], + "attributes": { + "doc_type": "help_article", + "category": article["category"], + "source_type": "confluence", + }, + "custom_attributes": { + "url": article.get("url", ""), + "tags": ["knowledge-base", article["category"]], }, }) - if len(batch) == 20: + if len(batch) == 100: all_ids += _upload_kb_batch(batch) batch = [] - time.sleep(1) if batch: all_ids += _upload_kb_batch(batch) @@ -187,12 +205,11 @@ def ingest_help_docs(articles: list) -> list: def _upload_kb_batch(batch: list) -> list: - # database / collection required as top-level SDK params AND inside each item result = client.context.ingest( database=TENANT_ID, collection=KB_SUB_TENANT, upsert=True, - app_knowledge=json.dumps(batch), # JSON string, not a list + context=json.dumps(batch), # JSON string, not a list ) return [r.id for r in (result.data.results or [])] ``` @@ -205,12 +222,11 @@ const TENANT_ID = "customer-support"; const KB_SUB_TENANT = "knowledge-base"; async function uploadKbBatch(batch: object[]): Promise { - // database / collection required as top-level SDK params AND inside each item const result = await client.context.ingest({ database: TENANT_ID, collection: KB_SUB_TENANT, upsert: true, - appKnowledge: JSON.stringify(batch), // JSON string, not an array + context: JSON.stringify(batch), // JSON string, not an array }); return (result.data.results || []).map((r: any) => r.id); } @@ -219,32 +235,31 @@ async function ingestHelpDocs(articles: any[]): Promise { /** * articles: array of objects - {id, title, content, category, url, updated_at} * category: "billing" | "onboarding" | "technical" | "account" | "general" - * updated_at: ISO 8601 - drives recency ranking + * updated_at: ISO 8601 or YYYY-MM-DD - drives recency ranking */ let batch: object[] = []; let allIds: string[] = []; for (const article of articles) { batch.push({ - id: article.id, - database: TENANT_ID, // required inside each item - collection: KB_SUB_TENANT, // required inside each item - title: article.title, - type: "confluence", - timestamp: article.updated_at, - content: { text: article.content }, - url: article.url || "", - metadata: { - doc_type: "help_article", - category: article.category, - tags: ["knowledge-base", article.category], + context_id: article.id, + title: article.title, + text: article.content, + happened_at: article.updated_at.slice(0, 10), + attributes: { + doc_type: "help_article", + category: article.category, + source_type: "confluence", + }, + custom_attributes: { + url: article.url || "", + tags: ["knowledge-base", article.category], }, }); - if (batch.length === 20) { + if (batch.length === 100) { allIds = allIds.concat(await uploadKbBatch(batch)); batch = []; - await new Promise(r => setTimeout(r, 1000)); } } @@ -285,24 +300,24 @@ def ingest_resolved_tickets(tickets: list) -> list: f"Customer plan: {ticket.get('plan_type', 'Unknown')}" ) batch.append({ - "id": f"ticket-{ticket['id']}", - "database": TENANT_ID, # required inside each item - "collection": KB_SUB_TENANT, # required inside each item - "title": ticket["subject"], - "type": "zendesk", - "timestamp": ticket["resolved_at"], - "content": {"text": content}, - "metadata": { - "doc_type": "resolved_ticket", - "category": ticket.get("category", "general"), + "context_id": f"ticket-{ticket['id']}", + "title": ticket["subject"], + "text": content, + "happened_at": ticket["resolved_at"][:10], + "attributes": { + "doc_type": "resolved_ticket", + "category": ticket.get("category", "general"), + "source_type": "zendesk", + }, + "custom_attributes": { "plan_type": ticket.get("plan_type", ""), "tags": ["resolved_ticket", ticket.get("category", "general")], }, }) - if len(batch) == 20: + if len(batch) == 100: all_ids += _upload_kb_batch(batch) - batch = []; time.sleep(1) + batch = [] if batch: all_ids += _upload_kb_batch(batch) @@ -332,25 +347,24 @@ async function ingestResolvedTickets(tickets: any[]): Promise { ].join("\n\n"); batch.push({ - id: `ticket-${ticket.id}`, - database: TENANT_ID, // required inside each item - collection: KB_SUB_TENANT, // required inside each item - title: ticket.subject, - type: "zendesk", - timestamp: ticket.resolved_at, - content: { text: content }, - metadata: { - doc_type: "resolved_ticket", - category: ticket.category || "general", + context_id: `ticket-${ticket.id}`, + title: ticket.subject, + text: content, + happened_at: ticket.resolved_at.slice(0, 10), + attributes: { + doc_type: "resolved_ticket", + category: ticket.category || "general", + source_type: "zendesk", + }, + custom_attributes: { plan_type: ticket.plan_type || "", tags: ["resolved_ticket", ticket.category || "general"], }, }); - if (batch.length === 20) { + if (batch.length === 100) { allIds = allIds.concat(await uploadKbBatch(batch)); batch = []; - await new Promise(r => setTimeout(r, 1000)); } } @@ -366,13 +380,13 @@ async function ingestResolvedTickets(tickets: any[]): Promise { --- -## Step 3 - Build Per-Customer Memory +## Step 3 - Build Per-Customer Context -Every customer gets their own persistent memory in HydraDB. This is what makes the agent feel personal. The memory contains every conversation turn, every preference signal, every product feedback item - and HydraDB continuously re-ranks which memories are most useful for the current interaction. +Every customer gets their own persistent context in HydraDB. This is what makes the agent feel personal. It contains every conversation turn, every preference signal, every product feedback item - and HydraDB continuously re-ranks which context is most useful for the current interaction. ### Store Conversation Turns -After every message exchange, write both the customer message and agent response to HydraDB. Use `infer: false` for verbatim storage - you want the exact words so future search can surface the precise prior exchange. +After every message exchange, write both the customer message and agent response to HydraDB. Use `enrich: false` for verbatim storage - you want the exact words so future search can surface the precise prior exchange. ```python Python SDK @@ -388,8 +402,8 @@ def store_conversation_turn( agent_reply: str, ): """ - user_name = customer_id so HydraDB builds a per-customer memory profile. - infer: false - store verbatim, don't extract implicit signals here. + user_name = customer_id so HydraDB builds a per-customer profile. + enrich: false - store verbatim, don't extract implicit signals here. """ text = ( f"[Ticket: {ticket_id}]\n" @@ -397,14 +411,14 @@ def store_conversation_turn( f"Agent: {agent_reply}" ) client.context.ingest( - type='memory', database=TENANT_ID, - collection=customer_sub_tenant(customer_id), + collection=customer_collection(customer_id), upsert=True, - memories=json.dumps([{ - "text": text, - "user_name": customer_id, # ties memory to this customer - "infer": False, # store verbatim conversation turn + context=json.dumps([{ + "context_id": f"turn-{ticket_id}", + "text": text, + "user_name": customer_id, # ties context to this customer + "enrich": False, # store verbatim conversation turn }]), ) ``` @@ -422,19 +436,19 @@ async function storeConversationTurn( agentReply: string, ): Promise { /** - * user_name = customerId so HydraDB builds a per-customer memory profile. - * infer: false - store verbatim, don't extract implicit signals here. + * user_name = customerId so HydraDB builds a per-customer profile. + * enrich: false - store verbatim, don't extract implicit signals here. */ const text = `[Ticket: ${ticketId}]\nCustomer: ${customerMsg}\nAgent: ${agentReply}`; await client.context.ingest({ - type: 'memory', database: TENANT_ID, collection: `customer-${customerId}`, - memories: JSON.stringify([{ + context: JSON.stringify([{ + context_id: `turn-${ticketId}`, text, - user_name: customerId, // ties memory to this customer - infer: false, // store verbatim conversation turn + user_name: customerId, // ties context to this customer + enrich: false, // store verbatim conversation turn }]), upsert: true, }); @@ -444,7 +458,7 @@ async function storeConversationTurn( ### Infer User Preferences -Store inferred preferences separately using `infer: true`. HydraDB extracts implicit signals from the text - preferred contact method, technical expertise level, frustration signals - and connects them to related context in the graph. +Store inferred preferences with `enrich: true` (the default). HydraDB extracts implicit signals from the text - preferred contact method, technical expertise level, frustration signals - and connects them to related context in the graph. ```python Python SDK @@ -455,18 +469,17 @@ import json def store_customer_preference(customer_id: str, preference: str): """ - infer: true - HydraDB extracts implicit signals and builds + enrich: true - HydraDB extracts implicit signals and builds graph connections to related context automatically. """ client.context.ingest( - type='memory', database=TENANT_ID, - collection=customer_sub_tenant(customer_id), + collection=customer_collection(customer_id), upsert=True, - memories=json.dumps([{ + context=json.dumps([{ "text": preference, - "user_name": customer_id, # ties memory to this customer - "infer": True, # HydraDB extracts preferences and signals + "user_name": customer_id, # ties context to this customer + "enrich": True, # HydraDB extracts preferences and signals }]), ) @@ -494,17 +507,16 @@ const TENANT_ID = "customer-support"; async function storeCustomerPreference(customerId: string, preference: string): Promise { /** - * infer: true - HydraDB extracts implicit signals and builds + * enrich: true - HydraDB extracts implicit signals and builds * graph connections to related context automatically. */ await client.context.ingest({ - type: 'memory', database: TENANT_ID, collection: `customer-${customerId}`, - memories: JSON.stringify([{ + context: JSON.stringify([{ text: preference, - user_name: customerId, // ties memory to this customer - infer: true, // HydraDB extracts preferences and signals + user_name: customerId, // ties context to this customer + enrich: true, // HydraDB extracts preferences and signals }]), upsert: true, }); @@ -533,7 +545,7 @@ await storeCustomerPreference( ## Step 4 - Handle a Support Request -When a customer opens a ticket, the agent makes two search calls to HydraDB, merges the results, then generates a response. `/query` searches the knowledge base; `/query` searches the customer's personal memory. Both are needed - neither alone returns the full picture. +When a customer opens a ticket, the agent makes two search calls to HydraDB, merges the results, then generates a response. The first `/query` call searches the knowledge base; the second searches the customer's personal context. Both are needed - neither alone returns the full picture. ### Search Customer Context @@ -548,10 +560,10 @@ def recall_customer_context( ) -> dict: """ Two-call search pattern: - 1. search (type="knowledge") - searches knowledge base (docs, resolved tickets) - 2. search (type="memory") - searches customer's personal memory + 1. /query on the knowledge-base collection (docs, resolved tickets) + 2. /query on the customer's own collection (preferences, history) Merge both before passing to LLM. - mode: "thinking" enables personalised ranking on both calls. + mode: "thinking" enables reranking on both calls. collection scopes each call to the right data store. """ # Call 1: knowledge base - help docs, resolved tickets, FAQs @@ -560,30 +572,32 @@ def recall_customer_context( collection=KB_SUB_TENANT, query=customer_msg, max_results=max_results, - mode="thinking", # personalised ranking + mode="thinking", graph_context=True, # cross-document entity linking alpha=0.8, # balanced semantic + keyword ) - # Call 2: customer personal memory - preferences, history, account facts + # Call 2: customer personal context - preferences, history, account facts mem_data = client.query( - type="memory", database=TENANT_ID, - collection=customer_sub_tenant(customer_id), + collection=customer_collection(customer_id), query=customer_msg, max_results=8, mode="thinking", ) - # Merge: personal memory first (higher personalization weight), - # then knowledge base chunks, then combined graph context + # Merge: personal context first (higher personalization weight), + # then knowledge base chunks. llm_prompt blocks are markdown, ready to + # concatenate into the LLM message. return { - "chunks": (mem_data.data.chunks or []) + (kb_data.data.chunks or []), - "graph_context": kb_data.data.graph_context or {}, + "chunks": (mem_data.data.chunks or []) + (kb_data.data.chunks or []), + "llm_prompt": "\n\n".join( + p for p in (mem_data.data.llm_prompt, kb_data.data.llm_prompt) if p + ), } - # chunks[n]["chunk_content"] - the actual text - # chunks[n]["source_title"] - which doc or memory it came from - # chunks[n]["relevancy_score"] - HydraDB's confidence (0–1) + # chunks[n].content - the actual text + # chunks[n].context_id - which context item it came from + # chunks[n].score - HydraDB's confidence (0-1) ``` ```typescript TypeScript SDK // support/search.ts @@ -597,13 +611,13 @@ async function recallCustomerContext( customerId: string, customerMsg: string, maxResults: number = 12, -): Promise<{ chunks: any[]; graphContext: any }> { +): Promise<{ chunks: any[]; llmPrompt: string }> { /** * Two-call search pattern: - * 1. fullRecall - searches knowledge base (docs, resolved tickets) - * 2. recallPreferences - searches customer's personal memory + * 1. /query on the knowledge-base collection (docs, resolved tickets) + * 2. /query on the customer's own collection (preferences, history) * Merge both before passing to LLM. - * mode: "thinking" enables personalised ranking on both calls. + * mode: "thinking" enables reranking on both calls. * collection scopes each call to the right data store. */ // Call 1: knowledge base - help docs, resolved tickets, FAQs @@ -612,14 +626,13 @@ async function recallCustomerContext( collection: KB_SUB_TENANT, query: customerMsg, maxResults: maxResults, - mode: "thinking", // personalised ranking + mode: "thinking", graphContext: true, // cross-document entity linking alpha: 0.8, // balanced semantic + keyword }); - // Call 2: customer personal memory - preferences, history, account facts + // Call 2: customer personal context - preferences, history, account facts const memData = await client.query({ - type: "memory", database: TENANT_ID, collection: `customer-${customerId}`, query: customerMsg, @@ -627,22 +640,24 @@ async function recallCustomerContext( mode: "thinking", }); - // Merge: personal memory first (higher personalization weight), - // then knowledge base chunks, then combined graph context + // Merge: personal context first (higher personalization weight), + // then knowledge base chunks. llm_prompt blocks are markdown, ready to + // concatenate into the LLM message. return { - chunks: (memData.data?.chunks || []).concat(kbData.data?.chunks || []), - graphContext: kbData.data?.graphContext || {}, + chunks: (memData.data?.chunks || []).concat(kbData.data?.chunks || []), + llmPrompt: [memData.data?.llm_prompt, kbData.data?.llm_prompt] + .filter(Boolean).join("\n\n"), }; - // chunks[n].chunk_content - the actual text - // chunks[n].source_title - which doc or memory it came from - // chunks[n].relevancy_score - HydraDB's confidence (0–1) + // chunks[n].content - the actual text + // chunks[n].context_id - which context item it came from + // chunks[n].score - HydraDB's confidence (0-1) } ``` ### Generate a Personalized Response -Pass the merged context to an LLM. The personal memory chunks surface what the customer's plan is, what they've already tried, and their communication preferences. The knowledge base chunks provide the actual solution. The LLM just needs to write the reply. +Pass the merged context to an LLM. Each query response's `data.llm_prompt` is a ready-made markdown block with citation labels, so the merge is a simple concatenation. The personal context surfaces what the customer's plan is, what they've already tried, and their communication preferences. The knowledge base provides the actual solution. The LLM just needs to write the reply. ```python Python SDK @@ -659,23 +674,12 @@ def handle_ticket( Full support handling flow: 1. Search customer context from HydraDB 2. Generate personalized response via LLM - 3. Store the exchange back into HydraDB memory + 3. Store the exchange back into HydraDB Returns the agent's reply string. """ - # Step 1: Search + # Step 1: Search - merged llm_prompt blocks, most useful context first context_data = recall_customer_context(customer_id, customer_msg) - chunks = context_data["chunks"] or [] - graph_ctx = context_data["graph_context"] - - # Build context string for the LLM - ranked chunks, most useful first - context_text = "\n\n".join( - f"[{c.source_title or ''} | score:{c.relevancy_score or 0:.2f}]\n{c.chunk_content or ''}" - for c in chunks - ) - - # Include entity relationship paths if available - entity_paths = graph_ctx.query_paths if graph_ctx else [] - entity_text = "\n".join(str(p) for p in entity_paths[:3]) + context_text = context_data["llm_prompt"] # Step 2: Generate response completion = openai_client.chat.completions.create( @@ -685,7 +689,7 @@ def handle_ticket( "role": "system", "content": ( "You are a customer support agent. Use ONLY the provided context to answer. " - "Adapt your tone and format to what the customer's memory profile indicates they prefer. " + "Adapt your tone and format to what the customer's profile indicates they prefer. " "If you see from prior tickets that something was already tried, do not suggest it again. " "If you cannot resolve the issue from the context, say so clearly and offer escalation. " "Always end with: is there anything else I can help you with?" @@ -695,8 +699,7 @@ def handle_ticket( "role": "user", "content": ( f"Customer message: {customer_msg}\n\n" - f"Context from HydraDB (use this to answer):\n{context_text}\n\n" - f"Related entity relationships:\n{entity_text}" + f"Context from HydraDB (use this to answer):\n{context_text}" ), }, ], @@ -704,7 +707,7 @@ def handle_ticket( ) reply = completion.choices[0].message.content - # Step 3: Store exchange in HydraDB memory for future personalization + # Step 3: Store exchange in HydraDB for future personalization store_conversation_turn(customer_id, ticket_id, customer_msg, reply) return reply @@ -724,22 +727,12 @@ async function handleTicket( * Full support handling flow: * 1. Search customer context from HydraDB * 2. Generate personalized response via LLM - * 3. Store the exchange back into HydraDB memory + * 3. Store the exchange back into HydraDB * Returns the agent's reply string. */ - // Step 1: Search + // Step 1: Search - merged llm_prompt blocks, most useful context first const contextData = await recallCustomerContext(customerId, customerMsg); - const chunks = contextData.chunks || []; - const graphCtx = contextData.graphContext || {}; - - // Build context string for the LLM - ranked chunks, most useful first - const contextText = chunks - .map((c: any) => `[${c.source_title} | score:${(c.relevancy_score || 0).toFixed(2)}]\n${c.chunk_content}`) - .join("\n\n"); - - // Include entity relationship paths if available - const entityPaths = (graphCtx.queryPaths || []).slice(0, 3); - const entityText = entityPaths.map((p: any) => String(p)).join("\n"); + const contextText = contextData.llmPrompt; // Step 2: Generate response const completion = await openaiClient.chat.completions.create({ @@ -749,7 +742,7 @@ async function handleTicket( role: "system", content: "You are a customer support agent. Use ONLY the provided context to answer. " + - "Adapt your tone and format to what the customer's memory profile indicates they prefer. " + + "Adapt your tone and format to what the customer's profile indicates they prefer. " + "If you see from prior tickets that something was already tried, do not suggest it again. " + "If you cannot resolve the issue from the context, say so clearly and offer escalation. " + "Always end with: is there anything else I can help you with?", @@ -758,15 +751,14 @@ async function handleTicket( role: "user", content: `Customer message: ${customerMsg}\n\n` + - `Context from HydraDB (use this to answer):\n${contextText}\n\n` + - `Related entity relationships:\n${entityText}`, + `Context from HydraDB (use this to answer):\n${contextText}`, }, ], temperature: 0.2, }); const reply = completion.choices[0].message.content!; - // Step 3: Store exchange in HydraDB memory for future personalization + // Step 3: Store exchange in HydraDB for future personalization await storeConversationTurn(customerId, ticketId, customerMsg, reply); return reply; @@ -774,8 +766,6 @@ async function handleTicket( ``` -> **Alternative - skip the LLM**: Use `POST /query` with `mode: "thinking"` and `collection: customer_id` to have HydraDB generate the answer directly. Faster, but less control over the system prompt and tone. - --- ## Step 5 - Escalation & Human Handoff @@ -796,17 +786,17 @@ def escalate_to_human( Escalate a ticket to a human agent with full HydraDB context. Returns the escalation payload ready to send to Zendesk, Linear, etc. """ - # Search full customer memory profile + # Search full customer context profile customer_profile = client.query( - type="memory", database=TENANT_ID, - collection=customer_sub_tenant(customer_id), + collection=customer_collection(customer_id), query="customer account history preferences past issues plan", ) # Search similar past tickets resolved by humans similar_data = client.query( database=TENANT_ID, + collection=KB_SUB_TENANT, query=f"{customer_msg} resolved escalated human agent", max_results=5, ) @@ -817,12 +807,12 @@ def escalate_to_human( "customer_id": customer_id, "current_issue": customer_msg, "ai_attempts": ai_attempts, - "customer_profile": customer_profile, + "customer_profile": customer_profile.data.llm_prompt or "", "similar_resolutions": [ { - "source": t.source_title or "", - "resolution": (t.chunk_content or "")[:500], - "score": t.relevancy_score or 0, + "source": t.context_id or "", + "resolution": (t.content or "")[:500], + "score": t.score or 0, } for t in similar_tickets ], @@ -833,7 +823,7 @@ def escalate_to_human( ), } - # Store escalation as a memory so future agents know it happened + # Store escalation as context so future agents know it happened store_customer_preference( customer_id, f"Ticket {ticket_id} was escalated to a human agent. " @@ -859,9 +849,8 @@ async function escalateToHuman( * Escalate a ticket to a human agent with full HydraDB context. * Returns the escalation payload ready to send to Zendesk, Linear, etc. */ - // Search full customer memory profile + // Search full customer context profile const customerProfile = await client.query({ - type: "memory", database: TENANT_ID, collection: `customer-${customerId}`, query: "customer account history preferences past issues plan", @@ -870,6 +859,7 @@ async function escalateToHuman( // Search similar past tickets resolved by humans const similarData = await client.query({ database: TENANT_ID, + collection: KB_SUB_TENANT, query: `${customerMsg} resolved escalated human agent`, maxResults: 5, }); @@ -880,11 +870,11 @@ async function escalateToHuman( customer_id: customerId, current_issue: customerMsg, ai_attempts: aiAttempts, - customer_profile: customerProfile, + customer_profile: customerProfile.data?.llm_prompt || "", similar_resolutions: similarTickets.map((t: any) => ({ - source: t.source_title, - resolution: t.chunk_content.slice(0, 500), - score: t.relevancy_score || 0, + source: t.context_id, + resolution: (t.content || "").slice(0, 500), + score: t.score || 0, })), note_for_human_agent: "Full context retrieved from HydraDB. " + @@ -892,7 +882,7 @@ async function escalateToHuman( "Check similar_resolutions for previously successful fixes.", }; - // Store escalation as a memory so future agents know it happened + // Store escalation as context so future agents know it happened await storeCustomerPreference( customerId, `Ticket ${ticketId} was escalated to a human agent. AI could not resolve: ${customerMsg.slice(0, 200)}` @@ -1009,7 +999,7 @@ if __name__ == "__main__": ``` Hi Sarah, -I can see you're on the Enterprise plan and that our team is already tracking this billing issue — it was flagged in your last conversation on May 12th as a known problem with the pro-rated credit calculation for annual upgrades. +I can see you're on the Enterprise plan and that our team is already tracking this billing issue - it was flagged in your last conversation on May 12th as a known problem with the pro-rated credit calculation for annual upgrades. Our engineering team is targeting a fix for this sprint (ending May 23rd). In the meantime, I've manually applied a $47 credit to your account to cover the discrepancy. @@ -1025,11 +1015,11 @@ All endpoints used in this cookbook. Base URL: `https://api.hydradb.com` · Head | Method | Endpoint | Purpose | |--------|----------|---------| | `POST` | `/databases` | Create the support database | -| `POST` | `/context/ingest` | Upload help docs and past tickets (SDK only) | +| `POST` | `/context/ingest` | Upload help docs and past tickets | | `GET` | `/context/status?database=...&ids=...` | Check indexing status | | `POST` | `/context/ingest` | Store conversation turns and preferences | | `POST` | `/query` | Search knowledge base | -| `POST` | `/query` | Retrieve customer personal memory | +| `POST` | `/query` | Retrieve customer personal context | ### Create Database @@ -1037,38 +1027,36 @@ All endpoints used in this cookbook. Base URL: `https://api.hydradb.com` · Head { "database": "customer-support" } ``` -### Upload Knowledge (via SDK) +### Upload Knowledge ```python client.context.ingest( database=TENANT_ID, collection=KB_SUB_TENANT, upsert=True, - app_knowledge=json.dumps([{ - "id": "kb-article-001", - "database": "customer-support", # also required inside each item - "collection": "knowledge-base", # also required inside each item - "title": "How to reset your SSO configuration", - "type": "confluence", - "timestamp": "2024-10-01T00:00:00Z", - "content": {"text": "Step 1: Go to Settings..."}, - "metadata": {"doc_type": "help_article", "category": "technical"} + context=json.dumps([{ + "context_id": "kb-article-001", + "title": "How to reset your SSO configuration", + "text": "Step 1: Go to Settings...", + "happened_at": "2024-10-01", + "attributes": {"doc_type": "help_article", "category": "technical", "source_type": "confluence"}, }]) ) ``` -### Store Customer Memory (Conversation Turn) +### Store Customer Context (Conversation Turn) ```json { - "memories": [{ - "text": "[Ticket: tkt-001]\nCustomer: My SSO is broken...\nAgent: Let's try...", - "user_name": "cust-8821", - "infer": false - }], - "database": "customer-support", + "database": "customer-support", "collection": "customer-cust-8821", - "upsert": true + "upsert": true, + "context": [{ + "context_id": "turn-tkt-001", + "text": "[Ticket: tkt-001]\nCustomer: My SSO is broken...\nAgent: Let's try...", + "user_name": "cust-8821", + "enrich": false + }] } ``` @@ -1076,14 +1064,14 @@ client.context.ingest( ```json { - "memories": [{ + "database": "customer-support", + "collection": "customer-cust-8821", + "upsert": true, + "context": [{ "text": "Customer prefers technical explanations. On Enterprise plan. SSO issue reported twice.", "user_name": "cust-8821", - "infer": true - }], - "database": "customer-support", - "collection": "customer-cust-8821", - "upsert": true + "enrich": true + }] } ``` @@ -1101,7 +1089,7 @@ client.context.ingest( } ``` -### Search Customer Memory +### Search Customer Context ```json { @@ -1122,8 +1110,8 @@ Tested across 2,400 real support tickets (mix of billing, technical, onboarding, |--------|--------------------|-----------------------|-------| | First-contact resolution rate | 38% | 71% | +87% | | "Agent knew my history" (CSAT signal) | 12% of sessions | 84% of sessions | +600% | -| Unnecessary escalation rate | 41% | 9% | −78% | -| Repeated troubleshooting steps (already tried) | 67% of tickets | 4% of tickets | −94% | +| Unnecessary escalation rate | 41% | 9% | -78% | +| Repeated troubleshooting steps (already tried) | 67% of tickets | 4% of tickets | -94% | | P95 search latency (HydraDB step) | N/A | under 200 ms | Sub-second | > The 94% drop in repeated troubleshooting steps is the most direct result of persistent memory. Without HydraDB, a customer who reports the same SSO issue for the third time gets the same "try clearing your browser cache" suggestion. With HydraDB, the agent knows that was already tried - and tried twice - and goes straight to the next level of diagnosis. @@ -1171,7 +1159,7 @@ slack-bolt # only if using Slack interface 1. Run `setup.py` to create your database and verify the connection. 2. Run the ingestion scripts with your real help docs and past tickets. -3. Seed a few customer memories from your CRM at account creation time. +3. Seed a few customer context items from your CRM at account creation time. 4. Wire `handle_ticket` into your existing support channel (email, Slack, or web chat). -The agent improves automatically - every conversation stored via `context.ingest(type="memory")` makes the next response for that customer more personalized. There is no retraining step. HydraDB re-ranks memories continuously as new interactions come in. +The agent improves automatically - every conversation stored in the customer's collection makes the next response for that customer more personalized. There is no retraining step. HydraDB re-ranks context continuously as new interactions come in. diff --git a/cookbooks/v2/glean-clone.mdx b/cookbooks/v2/glean-clone.mdx index 97740e70..22eca660 100644 --- a/cookbooks/v2/glean-clone.mdx +++ b/cookbooks/v2/glean-clone.mdx @@ -1,14 +1,11 @@ --- title: "Build your own Glean with HydraDB" description: "Learn how to build a comprehensive workplace search and AI assistant platform using HydraDB APIs. This guide covers data ingestion, retrieval, and app-layer answer generation across multiple data sources." -noindex: true --- -This page is deprecated: it documents the knowledge and memory API, which unified databases replace. See [Ingest context](/essentials/v2/ingest) and [Query](/essentials/v2/query). - This guide will walk you through building an extremely powerful workplace search and AI assistant platform that rivals Glean using HydraDB APIs. You'll learn how to create a unified retrieval experience across multiple data sources and generate answers in your application layer. -> **Note**: All code in this guide uses the official HydraDB Python SDK. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). +> **Note**: All code in this guide uses the official HydraDB Python SDK. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). SDK snippets use the call shapes in the [SDK reference](/api-reference/v2/sdks) and require an SDK release generated from the current API specification. ## Prerequisites @@ -23,7 +20,7 @@ This guide will walk you through building an extremely powerful workplace search By the end of this cookbook, you'll be able to: - Ingest documents from multiple data sources (Slack, email, Google Drive, Jira) into a unified HydraDB knowledge base - Run natural language workplace search across all sources with a single [`/query`](/api-reference/v2/endpoint/query) call -- Personalize search results per user by storing and retrieving AI memories +- Personalize search results per user by storing and retrieving per-user context - Generate grounded AI answers from retrieved context using any LLM ## Overview @@ -32,7 +29,7 @@ A Glean-like application typically includes these core features: - **Universal Search**: Search across multiple data sources (documents, emails, chats, etc.) - **Retrieval-Assisted Answers**: Generate intelligent answers from retrieved company knowledge -- **AI Memories for User Preferences**: Remember and adapt to individual user preferences, search patterns, and behavioral patterns +- **User Context for Preferences**: Remember and adapt to individual user preferences, search patterns, and behavioral patterns - **Data Ingestion**: Connect to various apps and services - **Knowledge Graph**: Build connections between information - **Security & Access Control**: Role-based permissions and data isolation @@ -43,11 +40,11 @@ A Glean-like application typically includes these core features: graph TD A["Frontend UI
• Search UI
• Chat Interface
• Results View"] B["Backend API
• Data Sync
• Auth/ACL
• App Connectors"] - C["HydraDB APIs
• Retrieval Engine
• Document Index
• Memory Store"] + C["HydraDB APIs
• Retrieval Engine
• Context Index
• Context Store"] D["Data Sources
• Google apps
• Slack
• Notion
• Jira"] E["App Connectors for Fetching Data

• Composio
• Vanilla APIs
• Webhooks
• Scheduled Jobs"] - F["Retrieval Layer
• HydraDB Memory
• User Sessions
• Metadata Store"] + F["Retrieval Layer
• HydraDB Context
• User Sessions
• Metadata Store"] A <--> B B <--> C @@ -171,37 +168,32 @@ class GmailConnector { ### 1.2 Data Normalization -Create a unified data format for all sources: +Create a unified data format for all sources. Each source document becomes a HydraDB `context` item: -> **Important**: For optimal performance, limit each batch to a maximum of **20 app sources** per request. Send multiple batch requests with an interval of **1 second** between each request. +> **Important**: For optimal performance, limit each batch to a maximum of **100 context items** per request. Send multiple batch requests with a short interval between each request. ```javascript -// Unified data structure for HydraDB app upload -const normalizedData = { - id: 'unique_id', - database: 'your_database', - collection: 'your_collection', +// Unified context item for HydraDB ingest +const normalizedItem = { + context_id: 'unique_id', // stable id for idempotent re-ingest with upsert title: 'Document/Message Title', - type: 'slack_message', // Source category: gmail, slack_message, notion_page, document, etc. - timestamp: '2024-01-01T00:00:00Z', // ISO timestamp - content: { - text: 'Main content text', - html_base64: 'base64_encoded_html', - markdown: 'markdown_content' + text: 'Main content text', // plain text extracted client-side + happened_at: '2024-01-01T00:00:00Z', // ISO timestamp + attributes: { // declared in the database metadata schema + source_type: 'slack', // gmail, slack, notion, drive, etc. + author: 'user@company.com' }, - url: 'https://app.com/item/123', // Optional: source URL - description: 'Optional description of the source', // Optional - metadata: {}, // Optional database-level metadata - additional_metadata: { - author: 'user@company.com', - id: 'original_id', - tags: ['project-a', 'urgent', 'meeting-notes'], - permissions: ['user1@company.com', 'user2@company.com'] - } + custom_attributes: { // free-form, non-filterable detail + url: 'https://app.com/item/123', + tags: 'project-a,urgent,meeting-notes' + }, + acl: ['user1@company.com', 'user2@company.com'] // Optional: per-item access control }; ``` +> **`attributes` must be declared.** Declare `source_type` and `author` (and any other filter keys) in `database_metadata_schema` when you create the database, as in [Attributes](/essentials/v2/attributes). Filter keys that are not declared cannot be used in query-time `attributes` filters. + ### 1.3 Batch Upload to HydraDB @@ -218,33 +210,28 @@ from hydra_db import HydraDB client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) -# Upload a batch of knowledge sources -def upload_batch(sources: list, database: str, collection: str = None): - app_knowledge = [ - {**source, "database": database, "collection": collection or database} - for source in sources - ] - - result = client.context.ingest( +# Ingest a batch of context items (up to 100 per request) +def upload_batch(items: list, database: str, collection: str): + return client.context.ingest( database=database, - app_knowledge=json.dumps(app_knowledge) + collection=collection, + upsert=True, + context=json.dumps(items), ) - return result -# Upload with verification — confirm each item is indexed before proceeding -def upload_with_verification(sources: list, database: str, collection: str = None): - upload_result = upload_batch(sources, database, collection) +# Ingest with verification - confirm each item is indexed before proceeding +def upload_with_verification(items: list, database: str, collection: str): + upload_result = upload_batch(items, database, collection) if upload_result.data.results: for item in upload_result.data.results: - id = item.id status = client.context.status( database=database, - ids=[id] + ids=[item.id] ) - items = status.data.statuses or [] - if items and items[0].indexing_status == "errored": - raise Exception(f"Processing failed for source {id}") + statuses = status.data.statuses or [] + if statuses and statuses[0].indexing_status == "errored": + raise Exception(f"Processing failed for source {item.id}") return upload_result ``` @@ -253,28 +240,22 @@ import { HydraDBClient } from "@hydradb/sdk"; const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY! }); -// Upload a batch of knowledge sources -const uploadBatch = async (sources: any[], database: string, collection?: string) => { - const appKnowledge = sources.map(source => ({ - ...source, - database: database, - collection: collection || database - })); - - const result = await client.context.ingest({ +// Ingest a batch of context items (up to 100 per request) +const uploadBatch = async (items: any[], database: string, collection: string) => { + return await client.context.ingest({ database: database, - appKnowledge: JSON.stringify(appKnowledge) + collection: collection, + upsert: true, + context: JSON.stringify(items) }); - - return result; }; -// Upload with verification — confirm each item is indexed before proceeding -const uploadWithVerification = async (sources: any[], database: string, collection?: string) => { - const uploadResult = await uploadBatch(sources, database, collection); +// Ingest with verification - confirm each item is indexed before proceeding +const uploadWithVerification = async (items: any[], database: string, collection: string) => { + const uploadResult = await uploadBatch(items, database, collection); - const items = uploadResult.data?.results ?? []; - for (const item of items) { + const results = uploadResult.data?.results ?? []; + for (const item of results) { const id = item.id; const status = await client.context.status({ database: database, @@ -298,7 +279,7 @@ const uploadWithVerification = async (sources: any[], database: string, collecti Create a search interface that queries across all data sources: -> **Note**: HydraDB supports filtering by `source_title` and `source_type` using the `metadata` parameter. Use these for targeted searches across specific data sources. +> **Note**: HydraDB supports filtering by declared attributes such as `source_type` using the `attributes` parameter. Attribute keys must be declared in the database's `database_metadata_schema` - see [Attributes](/essentials/v2/attributes). @@ -310,7 +291,7 @@ client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) def search(query: str, database: str, collection: str = None, max_results: int = 10, mode: str = "fast", - metadata: dict = None): + attributes: dict = None): return client.query( query=query, database=database, @@ -319,16 +300,12 @@ def search(query: str, database: str, collection: str = None, mode=mode, alpha=0.5, # Balance semantic vs keyword search (0.0 to 1.0) recency_bias=0.3, # Recency preference (0.0 to 1.0) - **({"metadata": metadata} if metadata else {}), + **({"attributes": attributes} if attributes else {}), ) # Filter search by source type def query_by_source_type(query: str, database: str, source_type: str): - return search(query, database, metadata={"source_type": source_type}) - -# Filter search by source title -def query_by_source_title(query: str, database: str, source_title: str): - return search(query, database, metadata={"source_title": source_title}) + return search(query, database, attributes={"source_type": source_type}) ``` ```typescript TypeScript SDK import { HydraDBClient } from "@hydradb/sdk"; @@ -339,7 +316,7 @@ interface SearchOptions { collection?: string; max_results?: number; mode?: string; - metadata?: Record; + attributes?: Record; } const search = async (query: string, database: string, options: SearchOptions = {}) => { @@ -347,7 +324,7 @@ const search = async (query: string, database: string, options: SearchOptions = collection, max_results = 10, mode = "fast", - metadata, + attributes, } = options; return await client.query({ @@ -358,17 +335,13 @@ const search = async (query: string, database: string, options: SearchOptions = mode, alpha: 0.5, // Balance semantic vs keyword search (0.0 to 1.0) recencyBias: 0.3, // Recency preference (0.0 to 1.0) - ...(metadata && { metadata }), + ...(attributes && { attributes }), }); }; // Filter search by source type const searchBySourceType = (query: string, database: string, sourceType: string) => - search(query, database, { metadata: { source_type: sourceType } }); - -// Filter search by source title -const searchBySourceTitle = (query: string, database: string, sourceTitle: string) => - search(query, database, { metadata: { source_title: sourceTitle } }); + search(query, database, { attributes: { source_type: sourceType } }); ``` @@ -392,13 +365,11 @@ from hydra_db import HydraDB client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) -# Search with optional source type / title metadata filters -def search_with_filters(query: str, database: str, source_types: str = None, source_titles: str = None): - metadata = {} - if source_types: - metadata["source_type"] = source_types - if source_titles: - metadata["source_title"] = source_titles +# Search with optional source type attribute filter +def search_with_filters(query: str, database: str, source_type: str = None): + attributes = {} + if source_type: + attributes["source_type"] = source_type return client.query( query=query, @@ -407,10 +378,10 @@ def search_with_filters(query: str, database: str, source_types: str = None, sou mode="fast", alpha=0.5, recency_bias=0.3, - **({"metadata": metadata} if metadata else {}) + **({"attributes": attributes} if attributes else {}) ) -# Guide retrieval with additional context — prepend context to the query string +# Guide retrieval with additional context - prepend context to the query string def search_with_context(query: str, database: str, context: str): return client.query( query=f"{context}\n\n{query}" if context else query, @@ -421,7 +392,7 @@ def search_with_context(query: str, database: str, context: str): recency_bias=0.3, ) -# Conversational search — fold prior conversation turns into the query +# Conversational search - fold prior conversation turns into the query def conversational_search(query: str, database: str, conversation_history: list = None): history = conversation_history or [] enriched_query = ( @@ -442,15 +413,14 @@ import { HydraDBClient } from "@hydradb/sdk"; const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY! }); -// Search with optional source type / title metadata filters +// Search with optional source type attribute filter const searchWithFilters = async ( query: string, database: string, - filters: { sourceTypes?: string; sourceTitles?: string } = {} + filters: { sourceType?: string } = {} ) => { - const metadata: Record = {}; - if (filters.sourceTypes) metadata.source_type = filters.sourceTypes; - if (filters.sourceTitles) metadata.source_title = filters.sourceTitles; + const attributes: Record = {}; + if (filters.sourceType) attributes.source_type = filters.sourceType; return await client.query({ query, @@ -459,11 +429,11 @@ const searchWithFilters = async ( mode: "fast", alpha: 0.5, recencyBias: 0.3, - ...(Object.keys(metadata).length > 0 && { metadata }) + ...(Object.keys(attributes).length > 0 && { attributes }) }); }; -// Guide retrieval with additional context — prepend context to the query string +// Guide retrieval with additional context - prepend context to the query string const searchWithContext = async (query: string, database: string, context: string) => client.query({ query: context ? `${context}\n\n${query}` : query, @@ -474,7 +444,7 @@ const searchWithContext = async (query: string, database: string, context: strin recencyBias: 0.3, }); -// Conversational search — fold prior conversation turns into the query +// Conversational search - fold prior conversation turns into the query const conversationalSearch = async ( query: string, database: string, @@ -494,20 +464,45 @@ const conversationalSearch = async (
-### 2.3 AI Memories and User Preferences +### 2.3 User Context and Preferences -One of the most powerful features of building a Glean-like application with HydraDB is leveraging **AI Memories** to create truly personalized experiences. HydraDB automatically manages AI memories using `collection` for user-level isolation. This allows your application to remember user preferences, past interactions, and behavioral patterns, making every search and interaction more relevant and efficient. +One of the most powerful features of building a Glean-like application with HydraDB is leveraging **per-user context** to create truly personalized experiences. Store each user's profile and interaction signals in their own `collection` (`user-`) for isolation, then query it before generating answers. This allows your application to remember user preferences, past interactions, and behavioral patterns, making every search and interaction more relevant and efficient. -#### Understanding AI Memories +#### Understanding User Context -HydraDB's AI memories are dynamic, user-specific profiles that evolve over time. They capture not just what users say, but their intentions, preferences, and unique behaviors. HydraDB automatically manages these memories using `collection` for user-level isolation. This enables your Glean clone to: +User context is a dynamic, user-specific profile that evolves over time. It captures not just what users say, but their intentions, preferences, and unique behaviors. Ingest profile updates with `enrich: true` so HydraDB extracts preference signals and links them into the context graph. This enables your Glean clone to: - **Remember User Preferences**: Format preferences, source preferences, search patterns - **Understand Intent**: Learn what types of information users typically seek - **Adapt Responses**: Tailor answers based on past interactions - **Anticipate Needs**: Suggest relevant information before users ask -#### Implementing AI Memories in Your Search +> **Stamp an `acl` on every per-user item.** An item without an `acl` is unrestricted, so an unscoped query could return another user's context. Store user signals with `acl: ["user_email:"]` and always pass the caller's email as the query `acl` - as `search_with_user_context` does below. Shared documents can stay unrestricted, or carry their own `acl` lists (department, domain, group) as in [Access control](/essentials/v2/access-control). + +```python +# Ingest a per-user signal with its ACL stamped +import json, os +from datetime import datetime +from hydra_db import HydraDB + +client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) +DATABASE = "your-database" + +def record_user_signal(user_id: str, user_email: str, text: str): + client.context.ingest( + database=DATABASE, + collection=f"user-{user_id}", + upsert=True, + context=json.dumps([{ + "context_id": f"signal-{user_id}-{int(datetime.now().timestamp())}", + "text": text, + "enrich": True, + "acl": [f"user_email:{user_email}"], + }]), + ) +``` + +#### Implementing User Context in Your Search @@ -518,7 +513,7 @@ from hydra_db import HydraDB client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) -# In-memory profile store — replace with your own database in production +# In-memory profile store - replace with your own database in production user_profiles: dict = {} def get_user_profile(user_id: str) -> dict: @@ -553,22 +548,24 @@ def save_user_profile(user_id: str, profile: dict): # Persist to your backend database print(f"Saving profile for user {user_id}:", profile) -# Search with personalized query enrichment derived from the user's local profile -def search_with_memory(query: str, database: str, user_id: str): +# Search with personalized query enrichment derived from the user's local profile. +# No collection scope: the query fans out across shared source collections and the +# user's own collection. acl restricts results to what the caller may retrieve. +def search_with_user_context(query: str, database: str, user_id: str, user_email: str): profile = get_user_profile(user_id) enriched_query = f"{build_personalized_instructions(profile, query)}\n\n{query}" kwargs = dict( query=enriched_query, database=database, - collection=user_id, + acl=[user_email], max_results=10, mode=profile["preferred_mode"] or "fast", alpha=0.5, recency_bias=0.3, ) if profile["preferred_source_types"]: - kwargs["metadata"] = {"source_type": profile["preferred_source_types"]} + kwargs["attributes"] = {"source_type": profile["preferred_source_types"]} search_results = client.query(**kwargs) @@ -584,8 +581,9 @@ def search_with_memory(query: str, database: str, user_id: str): profile["frequent_queries"] = ([query] + profile["frequent_queries"])[:10] for chunk in (search_results.data.chunks or []): - if chunk.source_title and chunk.source_title not in profile["favorite_sources"]: - profile["favorite_sources"].append(chunk.source_title) + source = (chunk.context_id or "").split("-")[0] # context_id is prefixed per source + if source and source not in profile["favorite_sources"]: + profile["favorite_sources"].append(source) profile["favorite_sources"] = profile["favorite_sources"][:5] profile["last_interaction"] = datetime.utcnow().isoformat() @@ -598,7 +596,7 @@ import { HydraDBClient } from "@hydradb/sdk"; const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY! }); -// In-memory profile store — replace with your own database in production +// In-memory profile store - replace with your own database in production const userProfiles = new Map(); const getUserProfile = (userId: string) => { @@ -633,21 +631,23 @@ const saveUserProfile = async (userId: string, profile: any) => { console.log(`Saving profile for user ${userId}:`, profile); }; -// Search with personalized query enrichment derived from the user's local profile -const searchWithMemory = async (query: string, database: string, userId: string) => { +// Search with personalized query enrichment derived from the user's local profile. +// No collection scope: the query fans out across shared source collections and the +// user's own collection. acl restricts results to what the caller may retrieve. +const searchWithUserContext = async (query: string, database: string, userId: string, userEmail: string) => { const userProfile = getUserProfile(userId); const enrichedQuery = `${buildPersonalizedInstructions(userProfile, query)}\n\n${query}`; const searchResults = await client.query({ query: enrichedQuery, database: database, - collection: userId, + acl: [userEmail], maxResults: 10, mode: userProfile.preferredMode || "fast", alpha: 0.5, recencyBias: 0.3, ...(userProfile.preferredSourceTypes?.length > 0 && { - metadata: { source_type: userProfile.preferredSourceTypes } + attributes: { source_type: userProfile.preferredSourceTypes } }) }); @@ -662,8 +662,9 @@ const searchWithMemory = async (query: string, database: string, userId: string) if (searchResults.data.chunks) { for (const chunk of searchResults.data.chunks) { - if (chunk.source && !userProfile.favoriteSources.includes(chunk.source)) { - userProfile.favoriteSources.push(chunk.source); + const source = (chunk.context_id ?? "").split("-")[0]; // context_id is prefixed per source + if (source && !userProfile.favoriteSources.includes(source)) { + userProfile.favoriteSources.push(source); } } userProfile.favoriteSources = userProfile.favoriteSources.slice(0, 5); @@ -782,17 +783,18 @@ class PreferenceLearner { ``` -#### Memory-Enhanced Search Interface +#### Personalized Search Interface ```javascript -const MemoryEnhancedSearch = ({ userId }) => { +// DATABASE, getUserProfile, saveUserProfile and searchWithUserContext are +// defined in the snippets above. DATABASE is your database name. +const PersonalizedSearchUI = ({ userId, userEmail }) => { const [query, setQuery] = useState(''); const [results, setResults] = useState(null); const [userPreferences, setUserPreferences] = useState(null); const [suggestions, setSuggestions] = useState([]); - const searchClient = new PersonalizedSearch(API_KEY, TENANT_ID); const preferenceLearner = new PreferenceLearner(); useEffect(() => { @@ -801,7 +803,7 @@ const MemoryEnhancedSearch = ({ userId }) => { }, [userId]); const loadUserPreferences = async () => { - const profile = await searchClient.getUserProfile(userId); + const profile = getUserProfile(userId); // local profile helper from step 2.3 setUserPreferences(profile); // Generate search suggestions based on user's history @@ -843,14 +845,14 @@ const MemoryEnhancedSearch = ({ userId }) => { const startTime = Date.now(); try { - const searchResults = await searchClient.searchWithMemory(query, userId); + const searchResults = await searchWithUserContext(query, DATABASE, userId, userEmail); setResults(searchResults); // Learn from this interaction const interaction = { query, selectedResults: [], // Will be populated when user clicks results - responseFormat: searchResults.format || 'default', + responseFormat: 'default', searchFilters: {}, timeSpent: Date.now() - startTime, followUpQueries: [] @@ -865,17 +867,18 @@ const MemoryEnhancedSearch = ({ userId }) => { const handleResultClick = async (result) => { // Update user preferences when they click on results - const profile = await searchClient.getUserProfile(userId); - - // Mark this source type as preferred - if (result.source && !profile.preferredSourceTypes.includes(result.source)) { - profile.preferredSourceTypes.push(result.source); - await searchClient.saveUserProfile(userId, profile); + const profile = getUserProfile(userId); + + // Mark this source type as preferred (context_id is prefixed per source) + const source = (result.context_id ?? '').split('-')[0]; + if (source && !profile.preferredSourceTypes.includes(source)) { + profile.preferredSourceTypes.push(source); + await saveUserProfile(userId, profile); } }; return ( -
+
{ ``` -#### Benefits of AI Memories in Your Glean Clone +#### Benefits of User Context in Your Glean Clone 1. **Personalized Search Results**: Users get results tailored to their preferences and past behavior 2. **Faster Information Discovery**: The system learns what sources and formats users prefer 3. **Improved User Experience**: Every interaction feels more personal and relevant 4. **Reduced Cognitive Load**: Users don't need to repeat their preferences or search patterns 5. **Adaptive Learning**: The system continuously improves based on user interactions -6. **Automatic Management**: HydraDB handles memory updates automatically - no manual implementation required +6. **Enrichment**: Ingest profile updates with `enrich: true` so HydraDB extracts preference signals and graph links automatically -#### Best Practices for AI Memories +#### Best Practices for User Context - **Respect Privacy**: Always give users control over their data and preferences - **Transparency**: Show users what preferences are being used and allow them to modify them @@ -1022,7 +1025,7 @@ const GleanSearchInterface = () => { ```javascript const SearchResults = ({ results }) => { - const { chunks, graphContext } = results.data ?? {}; + const { chunks, graph } = results.data ?? {}; return (
@@ -1035,10 +1038,12 @@ const SearchResults = ({ results }) => {
)} - {graphContext && graphContext.queryPaths && ( + {graph && graph.length > 0 && (
-

Related Knowledge Graph Paths

-
{JSON.stringify(graphContext.queryPaths, null, 2)}
+

Related Context Graph Paths

+ {graph.map((path, i) => ( +

{path.path_summary}

+ ))}
)}
@@ -1049,16 +1054,16 @@ const ChunkCard = ({ chunk }) => { return (
- {chunk.source} - {chunk.source_title} - {formatDate(chunk.timestamp)} + {(chunk.context_id ?? '').split('-')[0]} + {chunk.context_id} + score {chunk.score?.toFixed(2)}
-

{chunk.chunk_content}

- {chunk.bounding_box && ( -
- Position: {chunk.bounding_box.x}, {chunk.bounding_box.y} +

{chunk.content}

+ {chunk.enrichment && ( +
+ {chunk.enrichment}
)}
@@ -1075,9 +1080,8 @@ const ChunkCard = ({ chunk }) => { ```javascript class DataSyncManager { - constructor(connectors, cortexIngestion) { + constructor(connectors) { this.connectors = connectors; - this.cortexIngestion = cortexIngestion; this.syncIntervals = { slack: 5 * 60 * 1000, // 5 minutes gmail: 10 * 60 * 1000, // 10 minutes @@ -1105,8 +1109,8 @@ class DataSyncManager { this.normalizeData(item, connectorName) ); - // Use batch upload with verification - await this.cortexIngestion.uploadWithVerification(normalizedData); + // Use batched ingest with verification + await uploadWithVerification(normalizedData, DATABASE, connectorName); console.log(`Synced ${newData.length} items from ${connectorName}`); } } catch (error) { @@ -1132,24 +1136,18 @@ class DataSyncManager { normalizeData(item, sourceType) { return { - id: `${sourceType}_${item.id}`, + context_id: `${sourceType}-${item.id}`, title: item.title || item.subject || item.text?.substring(0, 100), - source: sourceType, - timestamp: item.timestamp || item.created_at || new Date().toISOString(), - content: { - text: item.text || item.body || item.content, - html_base64: item.html ? btoa(item.html) : '', - markdown: item.markdown || '' + text: item.text || item.body || item.content, + happened_at: item.timestamp || item.created_at || new Date().toISOString(), + attributes: { + source_type: sourceType, + author: item.author || item.user || '' }, - url: item.url, - description: item.description, - metadata: {}, - additional_metadata: { - id: item.id, - author: item.author || item.user, - tags: item.tags || [], - created_at: item.created_at, - updated_at: item.updated_at + custom_attributes: { + url: item.url || '', + tags: (item.tags || []).join(','), + updated_at: item.updated_at || '' } }; } @@ -1176,25 +1174,23 @@ def slack_webhook(): if event.get("type") == "message": from datetime import datetime - normalized_data = { - "id": f"slack_{event['ts']}", - "database": os.environ["TENANT_ID"], - "collection": os.environ["SUB_TENANT_ID"], + item = { + "context_id": f"slack-{event['channel']}-{event['ts']}", "title": f"Message in {event['channel']}", - "type": "slack_message", - "content": {"text": event["text"]}, - "metadata": {}, - "additional_metadata": { - "id": event["ts"], - "author": event["user"], - "created_at": datetime.utcfromtimestamp(float(event["ts"])).isoformat(), + "text": event["text"], + "happened_at": datetime.utcfromtimestamp(float(event["ts"])).isoformat(), + "user_name": event.get("user"), + "attributes": { + "source_type": "slack", "channel": event["channel"] } } client.context.ingest( database=os.environ["TENANT_ID"], - app_knowledge=json.dumps([normalized_data]) + collection="slack", + upsert=True, + context=json.dumps([item]) ) return "OK", 200 @@ -1209,25 +1205,23 @@ app.post("/webhooks/slack", async (req, res) => { const { event } = req.body; if (event.type === "message") { - const normalizedData = { - id: `slack_${event.ts}`, - database: process.env.TENANT_ID!, - collection: process.env.SUB_TENANT_ID!, + const item = { + context_id: `slack-${event.channel}-${event.ts}`, title: `Message in ${event.channel}`, - type: "slack_message", - content: { text: event.text }, - metadata: {}, - additional_metadata: { - id: event.ts, - author: event.user, - created_at: new Date(event.ts * 1000).toISOString(), + text: event.text, + happened_at: new Date(Number(event.ts) * 1000).toISOString(), + user_name: event.user, + attributes: { + source_type: "slack", channel: event.channel } }; await client.context.ingest({ database: process.env.TENANT_ID!, - appKnowledge: JSON.stringify([normalizedData]) + collection: "slack", + upsert: true, + context: JSON.stringify([item]) }); } @@ -1286,16 +1280,9 @@ class TenantManager { const searchOptions = { database, - collection, - metadata: { - database: database - } + collection }; - if (collection) { - searchOptions.metadata.collection = collection; - } - return await searchClient.search(query, searchOptions); } @@ -1324,16 +1311,16 @@ class DataPrivacyManager { cutoff.setDate(cutoff.getDate() - retentionDays); // DELETE /context deletes by explicit id - there is no date filter - so - // list the memories first and delete only those older than the cutoff. - const expiredIds = await this.findExpiredMemories(database, cutoff); + // list the context items first and delete only those older than the cutoff. + const expiredIds = await this.findExpiredItems(database, cutoff); if (expiredIds.length > 0) { await this.deleteOldData(database, expiredIds); } } } - async findExpiredMemories(database, cutoff, collection = null) { - const body = { type: 'memory', database: database }; + async findExpiredItems(database, cutoff, collection = null) { + const body = { database: database, include_fields: ['timestamp'] }; if (collection) { body.collection = collection; } @@ -1342,26 +1329,23 @@ class DataPrivacyManager { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, + 'API-Version': '2', 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const envelope = await response.json(); - // A type: 'memory' listing returns data.user_memories, not data.sources, and - // each row's identifier is memory_id (knowledge listings use data.sources - // with id). Reading data.sources here would always yield an empty array. - const memories = envelope.data?.user_memories ?? []; - - return memories - .filter((memory) => memory.timestamp && new Date(memory.timestamp) < cutoff) - .map((memory) => memory.memory_id); + const items = envelope.data?.sources ?? []; + + return items + .filter((item) => item.timestamp && new Date(item.timestamp) < cutoff) + .map((item) => item.id); } async deleteOldData(database, ids, collection = null) { - // Use HydraDB's context deletion endpoint for memory deletion. - // Raw fetch to the REST API: the JSON body uses snake_case wire keys. - const body = { type: 'memory', database: database, ids: ids }; + // DELETE /context takes {database, collection?, ids}. + const body = { database: database, ids: ids }; if (collection) { body.collection = collection; } @@ -1370,6 +1354,7 @@ class DataPrivacyManager { method: 'DELETE', headers: { 'Authorization': `Bearer ${API_KEY}`, + 'API-Version': '2', 'Content-Type': 'application/json' }, body: JSON.stringify(body) @@ -1512,7 +1497,7 @@ class SearchAnalytics { async trackSearch(query, filters, results, responseTime) { this.metrics.searches++; - if (results && results.answer) { + if (results && results.data?.chunks?.length) { this.metrics.successfulSearches++; } else { this.metrics.failedSearches++; @@ -1528,7 +1513,7 @@ class SearchAnalytics { // Track source type usage if (results && results.data?.chunks) { results.data.chunks.forEach(chunk => { - const sourceType = chunk.source; + const sourceType = (chunk.context_id ?? 'unknown').split('-')[0]; this.metrics.sourceTypeUsage.set( sourceType, (this.metrics.sourceTypeUsage.get(sourceType) || 0) + 1 @@ -1566,8 +1551,8 @@ class SearchAnalytics { ### 1. Data Ingestion Best Practices -- **Batch Processing**: Use HydraDB's batch upload endpoints for efficiency -- **Batch Limits**: Limit to 20 app sources per request with 1-second intervals between batches +- **Batch Processing**: Send up to 100 `context` items per `/context/ingest` request +- **Batch Limits**: Keep each request under the 8 MiB body and 1 MiB per-item limits - **Incremental Sync**: Only sync new/changed data to minimize API calls - **Error Handling**: Implement retry logic with exponential backoff - **Processing Verification**: Always verify upload processing using `/context/status` @@ -1577,7 +1562,7 @@ class SearchAnalytics { - **Query Preprocessing**: Clean and normalize user queries - **Result Ranking**: Use `alpha` and `recency_bias` for fine-tuning -- **Metadata Filtering**: Use `source_title` and `source_type` for targeted searches +- **Attribute Filtering**: Use declared `attributes` like `source_type` for targeted searches - **Thinking Mode**: Use `mode: "thinking"` for complex queries that benefit from multi-query retrieval with reranking - **Caching**: Cache frequent queries and results @@ -1593,7 +1578,7 @@ class SearchAnalytics { - **Connection Pooling**: Reuse HTTP connections - **Async Processing**: Use async/await for non-blocking operations - **Memory Management**: Implement proper cleanup for large datasets -- **Batch Optimization**: Respect 20-source batch limits and 1-second intervals +- **Batch Optimization**: Respect the 100-item batch limit and keep a short interval between batches - **Processing Verification**: Verify uploads to ensure data is properly indexed - **Monitoring**: Track response times and error rates @@ -1619,7 +1604,7 @@ class SearchAnalytics { Building a Glean-like application with HydraDB APIs provides you with a powerful, scalable foundation for workplace search and AI assistance. By following this guide and implementing the best practices outlined, you can create a comprehensive solution that rivals commercial offerings while maintaining full control over your data and user experience. -The key to success is starting with a solid architecture, implementing proper data synchronization, and gradually adding advanced features like multi-step reasoning, conversation memory, and personalized responses. HydraDB's APIs provide the AI capabilities you need, while your application handles the data ingestion, user interface, and business logic. +The key to success is starting with a solid architecture, implementing proper data synchronization, and gradually adding advanced features like multi-step reasoning, conversation context, and personalized responses. HydraDB's APIs provide the AI capabilities you need, while your application handles the data ingestion, user interface, and business logic. Remember to monitor performance, gather user feedback, and continuously iterate on your implementation to create the best possible user experience. @@ -1627,10 +1612,11 @@ Remember to monitor performance, gather user feedback, and continuously iterate | Date | Change | |------|--------| +| 2026-06-04 | Rewritten for the unified context API: `app_knowledge`/`type`-based calls replaced with `context` items, `metadata` filters replaced with declared `attributes`, memory features recast as per-user context collections, and response reads updated to `content`/`context_id`/`score`/`graph` | | 2026-05-14 | Replaced raw `fetch()`-based `HydraDBDataIngestion` class with official SDK calls (`client.context.ingest`, `client.context.status`) | | 2026-05-14 | Replaced raw `fetch()`-based `GleanSearch` class with `client.query` SDK call | | 2026-05-14 | Replaced `AdvancedSearch` class with standalone SDK helper functions (`searchWithFilters`, `searchWithContext`, `conversationalSearch`) | -| 2026-05-14 | Replaced `PersonalizedSearch` class with flat SDK-based `searchWithMemory` function | +| 2026-05-14 | Replaced `PersonalizedSearch` class with a flat SDK-based personalized search function | | 2026-05-14 | Replaced webhook `cortexIngestion.uploadBatch()` call with `client.context.ingest()` SDK call | | 2026-05-14 | Added Python SDK equivalents in `` tabs for all replaced code blocks | | 2026-05-14 | Removed "Status: In progress" notice | diff --git a/cookbooks/v2/hydradb-cookbook-06.mdx b/cookbooks/v2/hydradb-cookbook-06.mdx index d76f572e..40b61d1a 100644 --- a/cookbooks/v2/hydradb-cookbook-06.mdx +++ b/cookbooks/v2/hydradb-cookbook-06.mdx @@ -1,6 +1,6 @@ --- title: "AI Chief of Staff - Function Routing" -description: "Build an AI Chief of Staff that takes real actions across your workspace using HydraDB function routing. Register every callable function as a knowledge object in HydraDB. Any agent or user can say 'prepare for tomorrow's board meeting' and receive a structured execution plan." +description: "Build an AI Chief of Staff that takes real actions across your workspace using HydraDB function routing. Register every callable function as a context item in HydraDB. Any agent or user can say 'prepare for tomorrow's board meeting' and receive a structured execution plan." category: Automation difficulty: Advanced readTime: "60 min" @@ -8,15 +8,12 @@ tags: - automation - multi-agent - cookbook -noindex: true --- -This page is deprecated: it documents the knowledge and memory API, which unified databases replace. See [Ingest context](/essentials/v2/ingest) and [Query](/essentials/v2/query). - # Build an AI Chief of Staff -An AI that doesn't just answer questions - it *takes action*. Register every callable function in your workspace as a knowledge object in HydraDB. Any agent or user can say "prepare for tomorrow's board meeting" and receive a structured, personalized execution plan: which functions to call, in which order, with which parameters. Every API call is real and copy-paste ready. +An AI that doesn't just answer questions - it *takes action*. Register every callable function in your workspace as a context item in HydraDB. Any agent or user can say "prepare for tomorrow's board meeting" and receive a structured, personalized execution plan: which functions to call, in which order, with which parameters. Every API call is real and copy-paste ready. --- @@ -25,7 +22,7 @@ An AI that doesn't just answer questions - it *takes action*. Register every cal Most AI assistants are read-only. They answer questions, summarize documents, and draft emails. What they can't do is *act* - book the meeting, update the CRM, send the Slack message, trigger the deployment. To cross that threshold, the agent needs to know not just what functions exist in your workspace, but which one to call for any given task, in what order, with what parameters, and for which user. -This cookbook builds an **AI Chief of Staff** - an autonomous reasoning layer that turns natural language into structured function calls across every app in your stack. Think of it as n8n, but driven by intent rather than rigid if-then workflows. You register your callable functions into HydraDB as knowledge objects. Any agent then asks HydraDB: *"What should I do for this task?"* HydraDB returns the right function, the right parameters, and the right sequence - all personalized to the requesting user's preferences and the current context. +This cookbook builds an **AI Chief of Staff** - an autonomous reasoning layer that turns natural language into structured function calls across every app in your stack. Think of it as n8n, but driven by intent rather than rigid if-then workflows. You register your callable functions into HydraDB as context items. Any agent then asks HydraDB: *"What should I do for this task?"* HydraDB returns the right function, the right parameters, and the right sequence - all personalized to the requesting user's preferences and the current context. The architectural insight is separation of concerns: your primary LLM handles conversation and intent extraction, while HydraDB becomes the **function selection oracle** - a reasoning layer that has learned which functions work for which tasks, which sequences tend to succeed together, and how individual users prefer to work. Over time, it builds institutional knowledge that your agents can tap into. @@ -47,10 +44,10 @@ The architectural insight is separation of concerns: your primary LLM handles co ## What You'll Build By the end of this cookbook, you'll be able to: -- Register any workspace function (Slack, Calendar, CRM, Jira) as a HydraDB knowledge object so agents can discover it semantically +- Register any workspace function (Slack, Calendar, CRM, Jira) as a HydraDB context item so agents can discover it semantically - Build an Orchestrator that translates a natural-language task into an authorized, token-injected API call - Generate multi-step execution plans for complex tasks like "onboard the new hire" -- Store per-user preference memory so HydraDB personalizes function suggestions over time +- Store per-user preference context so HydraDB personalizes function suggestions over time - Feed execution outcomes back into HydraDB to close the self-improvement loop @@ -65,7 +62,7 @@ By the end of this cookbook, you'll be able to: **✅ // HydraDB AI Chief of Staff** - Intent-driven routing - adapts to how requests are phrased -- Per-user personalization via AI Memories +- Per-user personalization via user context - Learns function composition patterns from execution history - One natural language request returns a full execution plan - Compound intelligence - every run makes future runs smarter @@ -77,8 +74,8 @@ By the end of this cookbook, you'll be able to: Four HydraDB capabilities make a Chief of Staff possible: -- **Functions as knowledge objects** - each callable function is uploaded to HydraDB via `POST /context/ingest` with `type: "function"`. The function's natural-language description becomes the retrieval surface. HydraDB matches tasks to functions semantically - not by keyword - so "tell the team about the delay" correctly surfaces `send_slack_announcement` even though neither word appears in the function name. -- **Personalized function selection** - when a user frequently chooses Slack over email for urgent updates, HydraDB's AI Memories encode that preference. Future function suggestions for that user automatically favour `send_slack_message` over `send_email`. This happens without any manual configuration - the pattern emerges from usage stored via `POST /context/ingest`. +- **Functions as context items** - each callable function is uploaded to HydraDB via `POST /context/ingest` with an `attributes` tag of `doc_type: "function"`. The function's natural-language description becomes the retrieval surface. HydraDB matches tasks to functions semantically - not by keyword - so "tell the team about the delay" correctly surfaces `send_slack_announcement` even though neither word appears in the function name. +- **Personalized function selection** - when a user frequently chooses Slack over email for urgent updates, HydraDB's per-user context encodes that preference. Future function suggestions for that user automatically favour `send_slack_message` over `send_email`. This happens without any manual configuration - the pattern emerges from usage stored via `POST /context/ingest`. - **Multi-step plan generation** - `mode: "thinking"` on `POST /query` enables multi-query reasoning. Ask HydraDB to return a JSON array of functions with dependencies and it decomposes a complex request like "onboard the new hire" into a sequenced execution plan automatically. - **Self-improving function routing** - feeding execution results back to HydraDB via `POST /context/ingest` closes the learning loop. Slow functions, failed calls, and successful sequences all become training signal. The agent gets measurably smarter with every run, without any manual tuning. @@ -86,7 +83,7 @@ Four HydraDB capabilities make a Chief of Staff possible: ## Architecture -One HydraDB database. Functions registered as knowledge objects. An Action Orchestrator that translates HydraDB suggestions into real API calls. Per-user memories that personalize every suggestion. +One HydraDB database. Functions registered as context items. An Action Orchestrator that translates HydraDB suggestions into real API calls. Per-user context collections that personalize every suggestion. ```mermaid @@ -99,12 +96,12 @@ flowchart LR F --> G["External API
(function execution)"] G --> H["Execution result"] H --> C - H --> I["Per-user memories"] + H --> I["Per-user context"] I --> C ``` -The flow: a user or agent sends a natural-language task to the Action Orchestrator. The Orchestrator queries HydraDB, which matches the task semantically against registered function knowledge objects and returns a ranked suggestion. The Orchestrator executes the function via the real API, logs the result back to HydraDB as a memory, and the loop closes. Each execution makes the next suggestion smarter. +The flow: a user or agent sends a natural-language task to the Action Orchestrator. The Orchestrator queries HydraDB, which matches the task semantically against registered function context items and returns a ranked suggestion. The Orchestrator executes the function via the real API, logs the result back to HydraDB as context, and the loop closes. Each execution makes the next suggestion smarter. ℹ️ @@ -117,7 +114,7 @@ The flow: a user or agent sends a natural-language task to the Action Orchestrat ## Create Database -One database for the whole Chief of Staff system. All functions, all user memories, and all execution history live under this database. Collections scope function access per team or department - the sales team's agent only sees sales functions, the engineering team's agent only sees deployment and monitoring functions. +One database for the whole Chief of Staff system. All functions, all user context, and all execution history live under this database. Collections scope function access per team or department - the sales team's agent only sees sales functions, the engineering team's agent only sees deployment and monitoring functions. Install the Python packages used by the examples and set both API keys: @@ -127,10 +124,10 @@ export HYDRA_DB_API_KEY="your_hydradb_key" export OPENAI_API_KEY="your_openai_key" ``` -HydraDB handles retrieval and memory. The OpenAI SDK is used only in the app layer for turning retrieved function schemas into executable parameters and multi-step plans; you can replace it with any LLM provider. +HydraDB handles retrieval and personalization. The OpenAI SDK is used only in the app layer for turning retrieved function schemas into executable parameters and multi-step plans; you can replace it with any LLM provider. ```python title="setup.py" -import os +import os, time from hydra_db import HydraDB API_KEY = os.environ["HYDRA_DB_API_KEY"] @@ -140,7 +137,18 @@ client = HydraDB(token=API_KEY) def create_tenant(): """Create the main database. Idempotent - safe to call multiple times.""" - client.databases.create(database=TENANT_ID) + client.databases.create( + database=TENANT_ID, + database_metadata_schema=[ + {"name": "doc_type", "data_type": "VARCHAR"}, + {"name": "department", "data_type": "VARCHAR"}, + {"name": "permission_level", "data_type": "VARCHAR"}, + {"name": "deprecated", "data_type": "BOOL"}, + ], + ) + # Database creation is asynchronous - poll until ready before ingesting + while not client.databases.status(database=TENANT_ID).data.infra.ready_for_ingestion: + time.sleep(4) print(f"Database '{TENANT_ID}' ready.") # Collections scope functions per team. Created automatically on first write. @@ -167,7 +175,7 @@ Database 'chief-of-staff' ready. ## Define & Register Functions -Every action your Chief of Staff can take must be registered in HydraDB as a knowledge object. HydraDB treats each function as a document: its natural-language description is the retrieval surface, its schema is the execution contract, and its metadata controls who can access it and under what conditions. +Every action your Chief of Staff can take must be registered in HydraDB as a context item. HydraDB treats each function as a document: its natural-language description is the retrieval surface, its schema is the execution contract, and its metadata controls who can access it and under what conditions. The quality of your function descriptions directly determines routing accuracy. Write descriptions that explain *what the function achieves* and *when it should be used*, not just what it does technically. HydraDB reasons over these descriptions during function selection. @@ -232,7 +240,7 @@ Here is a more complex schema - a finance function with approval constraints and "meta": { "department": "finance", "permission_level": "manager", - "cost_threshold": 5000, // escalate to CFO above this + "cost_threshold": 5000, "business_hours_only": true, "collections": ["finance", "approvals"], "side_effects": "Triggers payment processing. Irreversible without finance team intervention.", @@ -245,7 +253,7 @@ Here is a more complex schema - a finance function with approval constraints and ### Upload to HydraDB -Upload functions using the same `POST /context/ingest` endpoint used for documents. Set `type: "function"` and include the full JSON schema as the content body. Group functions into collections so HydraDB can scope retrieval per team without returning irrelevant options. +Upload functions using `POST /context/ingest`. Tag each item with `attributes: {"doc_type": "function"}` and put the full JSON schema in `text`. Group functions into collections so HydraDB can scope retrieval per team without returning irrelevant options. ```python title="register/upload_functions.py" @@ -276,11 +284,11 @@ def load_schema(path: str) -> dict: def upload_functions(schema_paths: list, collection: str = "functions") -> list: """ - Upload function schemas to HydraDB as knowledge objects. + Upload function schemas to HydraDB as context items. collection: scopes which agents can see these functions. Use per-team collections to limit scope and improve routing precision. - Tip: re-running this is idempotent - HydraDB upserts on 'id'. + Tip: re-running this is idempotent - upsert replaces on 'context_id'. """ batch = [] all_ids = [] @@ -290,39 +298,40 @@ def upload_functions(schema_paths: list, collection: str = "functions") -> list: fn_id = schema["id"] batch.append({ - "id": fn_id, - "title": schema["name"], - "type": "function", # tells HydraDB this is callable - "timestamp": "2025-01-01T00:00:00Z", - "content": {"text": json.dumps(schema, indent=2)}, - "metadata": { - "type": "function", - "collections": schema.get("meta", {}).get("collections", []), - "department": schema.get("meta", {}).get("department", "all"), - "permissions": schema.get("meta", {}).get("permissions", ["all_users"]), - "idempotent": schema.get("meta", {}).get("idempotent", True), + "context_id": fn_id, + "title": schema["name"], + "text": json.dumps(schema, indent=2), + "attributes": { + "doc_type": "function", # tells HydraDB this is callable + "department": schema.get("meta", {}).get("department", "all"), + "permission_level": schema.get("meta", {}).get("permission_level", "contributor"), + "deprecated": False, + }, + "custom_attributes": { + "idempotent": str(schema.get("meta", {}).get("idempotent", True)).lower(), "side_effects": schema.get("meta", {}).get("side_effects", ""), }, }) - if len(batch) == 20: - all_ids += _upload_batch(batch, collection) - batch = []; time.sleep(1) + if len(batch) == 100: + all_ids += _ingest_batch(batch, collection) + batch = [] if batch: - all_ids += _upload_batch(batch, collection) + all_ids += _ingest_batch(batch, collection) print(f"Functions: {len(all_ids)} schemas indexed.") return all_ids -def _upload_batch(batch: list, collection: str) -> list: - data = client.context.ingest( +def _ingest_batch(batch: list, collection: str) -> list: + result = client.context.ingest( database=TENANT_ID, collection=collection, - app_knowledge=json.dumps(batch), + upsert=True, + context=json.dumps(batch), ) - return [item.id for item in (data.results or []) if item.id] + return [item.id for item in (result.data.results or []) if item.id] # Upload all functions (all teams, scoped to "functions" collection) @@ -344,7 +353,7 @@ Functions: 10 schemas indexed. ### Versioning & deprecation -As functions evolve, use a `_v2` suffix on the ID for new versions. Mark deprecated versions in metadata so HydraDB stops routing to them while preserving historical execution records. Never delete old function objects - they anchor memory traces from past executions. +As functions evolve, use a `_v2` suffix on the ID for new versions. Mark deprecated versions with `attributes: {"deprecated": true}` so HydraDB stops routing to them while preserving historical execution records. Never delete old function items - they anchor context from past executions. ```python title="register/versioning.py" @@ -359,21 +368,23 @@ def deprecate_function(fn_id: str, collection: str, reason: str): """ Mark a function as deprecated so HydraDB stops suggesting it. Never delete - old executions reference this ID for audit and provenance. - Use 'deprecated: true' in metadata + upload new version as fn_id_v2. + Set 'deprecated: true' in attributes + upload new version as fn_id_v2. """ client.context.ingest( database=TENANT_ID, collection=collection, - app_knowledge=json.dumps([{ - "id": fn_id, - "title": f"[DEPRECATED] {fn_id}", - "type": "function", - "timestamp": "2025-01-01T00:00:00Z", - "content": {"text": f"DEPRECATED: {reason}. Use {fn_id}_v2 instead."}, - "metadata": { - "deprecated": True, - "deprecated_reason": reason, - "successor_id": f"{fn_id}_v2", + upsert=True, + context=json.dumps([{ + "context_id": fn_id, + "title": f"[DEPRECATED] {fn_id}", + "text": f"DEPRECATED: {reason}. Use {fn_id}_v2 instead.", + "attributes": { + "doc_type": "function", + "deprecated": True, + }, + "custom_attributes": { + "deprecated_reason": reason, + "successor_id": f"{fn_id}_v2", }, }]), ) @@ -433,7 +444,7 @@ class ChiefOfStaffOrchestrator: task: str, # natural language - "book a 30-min call with alice next tuesday" user_id: str, session_id: str = None, - sub_tenant: str = "functions", + collection: str = "functions", ) -> dict: """ Single-function task handling. @@ -441,18 +452,18 @@ class ChiefOfStaffOrchestrator: 2. Authorize against policy engine. 3. Inject OAuth token from vault. 4. Execute via registry callable. - 5. Log result back to HydraDB as memory. + 5. Log result back to HydraDB as context. """ session_id = session_id or str(uuid.uuid4()) # Step 1 - Ask HydraDB for the best function search = client.query( database=TENANT_ID, - collection=sub_tenant, + collection=collection, query=task, max_results=5, mode="thinking", - metadata_filters={"deprecated": False}, + attributes={"deprecated": False}, ) chunks = search.data.chunks or [] @@ -461,7 +472,7 @@ class ChiefOfStaffOrchestrator: # Top chunk is the best-matching function schema top_chunk = chunks[0] - schema = json.loads(top_chunk.chunk_content) + schema = json.loads(top_chunk.content) function_id = schema["id"] # Step 2 - Authorize: check user permissions against policy engine @@ -484,7 +495,7 @@ class ChiefOfStaffOrchestrator: result = exec_fn(params) - # Step 6 - Log outcome to HydraDB memory for self-improvement + # Step 6 - Log outcome to HydraDB for self-improvement self._log_execution(user_id, task, function_id, params, result) return {"status": "done", "function_id": function_id, "result": result} @@ -519,11 +530,10 @@ class ChiefOfStaffOrchestrator: success = result.get("success", True) outcome = "success" if success else "failure" client.context.ingest( - type='memory', database=TENANT_ID, collection=f"user-{user_id}", upsert=True, - memories=json.dumps([{ + context=json.dumps([{ "text": ( f"Task: {task}\n" f"Function used: {function_id}\n" @@ -531,25 +541,27 @@ class ChiefOfStaffOrchestrator: f"Summary: {str(result.get('summary',''))[:300]}" ), "user_name": user_id, - "infer": True, + "enrich": True, + "attributes": {"doc_type": "execution_outcome"}, }]), ) def _log_blocked(self, user_id, task, function_id): """Log a blocked attempt for audit trail.""" client.context.ingest( - type='memory', database=TENANT_ID, + collection="execution-log", upsert=True, - memories=json.dumps([{ + context=json.dumps([{ "text": f"BLOCKED: User {user_id} attempted {function_id} for task: {task}. Policy denied.", "user_name": "audit-log", - "infer": False, + "enrich": False, + "attributes": {"doc_type": "audit"}, }]), ) ``` -> **Note**: Use `metadata_filters` for hard, exact routing constraints. Top-level keys match schema-backed `metadata` fields such as `deprecated`; free-form per-source fields belong under `additional_metadata`. +> **Note**: Use `attributes` filters for hard, exact routing constraints. Keys must be declared in the database's `database_metadata_schema` - `deprecated` is declared as `BOOL` here, so the filter value is the boolean `false`, not a string. ### Function registry @@ -621,7 +633,7 @@ FUNCTION_REGISTRY = { ### Result feedback loop -The feedback loop is what separates a static function router from a learning system. After every execution, write a structured memory to HydraDB with `infer: true`. HydraDB extracts: which function was chosen, whether it succeeded, and what the user was trying to do. Over time, these signals shift the function preference profile for each user, making suggestions increasingly accurate without any manual tuning. +The feedback loop is what separates a static function router from a learning system. After every execution, write a structured context item to HydraDB with `enrich: true`. HydraDB extracts: which function was chosen, whether it succeeded, and what the user was trying to do. Over time, these signals shift the function preference profile for each user, making suggestions increasingly accurate without any manual tuning. ```python title="orchestrator/feedback.py" @@ -642,8 +654,8 @@ def log_function_feedback( details: str = "", ): """ - Write execution feedback as a memory so HydraDB learns from outcomes. - infer: true - HydraDB extracts preference signals and builds graph links + Write execution feedback as context so HydraDB learns from outcomes. + enrich: true - HydraDB extracts preference signals and builds graph links between this user, this function, and similar tasks. outcome="user_rejected" is especially valuable: the agent suggested the @@ -660,14 +672,14 @@ def log_function_feedback( text += f"Details: {details}" client.context.ingest( - type='memory', database=TENANT_ID, collection=f"user-{user_id}", upsert=True, - memories=json.dumps([{ - "text": text, - "user_name": user_id, - "infer": True, + context=json.dumps([{ + "text": text, + "user_name": user_id, + "enrich": True, + "attributes": {"doc_type": "execution_outcome"}, }]), ) @@ -688,16 +700,16 @@ log_function_feedback( STEP 4 -## Store Agent Memory +## Store Agent Context -Two types of memory drive personalization. **User preference memory** stores how each person prefers to work - which channels they favour, which functions they trust, how they phrase requests. **Execution outcome memory** stores what happened when functions were called - successes, failures, latency patterns, user corrections. Together, these build a complete model of each user's working style that HydraDB uses to shift function suggestion rankings on every search. +Two kinds of context drive personalization. **User preference context** stores how each person prefers to work - which channels they favour, which functions they trust, how they phrase requests. **Execution outcome context** stores what happened when functions were called - successes, failures, latency patterns, user corrections. Together, these build a complete model of each user's working style that HydraDB uses to shift function suggestion rankings on every search. -### User preference memory +### User preference context -Write explicit preference profiles during onboarding and update them whenever a user changes how they work. Use `infer: true` so HydraDB extracts the implicit signals - channel preferences, communication style, urgency thresholds - and builds graph connections to related functions automatically. +Write explicit preference profiles during onboarding and update them whenever a user changes how they work. Use `enrich: true` so HydraDB extracts the implicit signals - channel preferences, communication style, urgency thresholds - and builds graph connections to related functions automatically. ```python title="memory/user_preferences.py" @@ -712,21 +724,23 @@ TENANT_ID = "chief-of-staff" def store_user_preferences(user_id: str, profile: str): """ Store a user's working preferences so HydraDB personalizes function - suggestions for them. infer: true - HydraDB extracts channel preferences, + suggestions for them. enrich: true - HydraDB extracts channel preferences, urgency signals, communication style, and links these to specific functions. Call during onboarding and whenever preferences change. - Use the same user_id consistently across all memory writes for this user. + Use the same user_id consistently across all writes for this user. """ client.context.ingest( - type='memory', database=TENANT_ID, collection=f"user-{user_id}", upsert=True, - memories=json.dumps([{ - "text": profile, - "user_name": user_id, - "infer": True, + context=json.dumps([{ + "context_id": f"preferences-{user_id}", + "title": f"{user_id} preferences", + "text": profile, + "user_name": user_id, + "enrich": True, + "attributes": {"doc_type": "user_profile"}, }]), ) @@ -738,7 +752,7 @@ store_user_preferences( "Sarah is the VP of Engineering. She prefers Slack DMs over email for all internal " "communication. For urgent issues she always uses PagerDuty, not Jira. " "She approves expenses only during business hours. " - "Her calendar blocks 9–10am daily for deep work - never schedule meetings there. " + "Her calendar blocks 9-10am daily for deep work - never schedule meetings there. " "She likes executive summaries, not raw data. Always call generate_report before " "presenting metrics to her." ), @@ -764,10 +778,10 @@ HydraDB will now personalize function suggestions for both users. ``` -### Execution outcome memory +### Execution outcome context -Beyond preferences, HydraDB needs to know what actually happened. Store each execution outcome as a memory with enough detail for HydraDB to identify patterns: which functions tend to succeed together, which fail under specific conditions, which are consistently slow. Use `infer: false` for exact outcome records and `infer: true` for synthesized pattern summaries. +Beyond preferences, HydraDB needs to know what actually happened. Store each execution outcome as a context item with enough detail for HydraDB to identify patterns: which functions tend to succeed together, which fail under specific conditions, which are consistently slow. Use `enrich: false` for exact outcome records and `enrich: true` for synthesized pattern summaries. ```python title="memory/outcomes.py" @@ -789,21 +803,23 @@ def log_execution_outcome( chained_fns: list = None, # other functions called in the same task ): """ - Log an execution outcome verbatim (infer: false) for the audit trail. - Also write a synthesized pattern summary (infer: true) for learning. + Log an execution outcome verbatim (enrich: false) for the audit trail. + Also write a synthesized pattern summary (enrich: true) for learning. These two writes serve different purposes: - - infer: false → exact record, queryable for compliance and audit - - infer: true → HydraDB extracts patterns and links to similar tasks + - enrich: false → exact record, queryable for compliance and audit + - enrich: true → HydraDB extracts patterns and links to similar tasks """ ts = datetime.now(timezone.utc).isoformat() # Write 1: exact record client.context.ingest( - type='memory', database=TENANT_ID, collection="execution-log", upsert=True, - memories=json.dumps([{ + context=json.dumps([{ + "context_id": f"exec-{function_id}-{ts}", + "title": f"{function_id} outcome {ts}", + "happened_at": ts, "text": ( f"[{ts}] user={user_id} fn={function_id} " f"outcome={outcome} latency={latency_ms}ms\n" @@ -812,7 +828,8 @@ def log_execution_outcome( f"error={error_msg or 'none'}" ), "user_name": "system", - "infer": False, + "enrich": False, + "attributes": {"doc_type": "execution_outcome"}, }]), ) @@ -829,14 +846,14 @@ def log_execution_outcome( summary += f"User manually overrode this suggestion for user_id={user_id}." client.context.ingest( - type='memory', database=TENANT_ID, collection=f"user-{user_id}", upsert=True, - memories=json.dumps([{ - "text": summary, - "user_name": user_id, - "infer": True, + context=json.dumps([{ + "text": summary, + "user_name": user_id, + "enrich": True, + "attributes": {"doc_type": "execution_outcome"}, }]), ) ``` @@ -852,7 +869,7 @@ Many real-world tasks require more than one function call. "Onboard the new hire ℹ️ -> **Use `mode: "thinking"` for plan generation.** `mode: "thinking"` enables HydraDB's multi-query decomposition - it breaks the task into sub-questions, matches each to a function, and assembles the ordered plan. `mode: "fast"` returns a single best-match function. Always use `"thinking"` when the task is complex or ambiguous. Plan generation typically takes 200–600ms. +> **Use `mode: "thinking"` for plan generation.** `mode: "thinking"` enables HydraDB's multi-query decomposition - it breaks the task into sub-questions, matches each to a function, and assembles the ordered plan. `mode: "fast"` returns a single best-match function. Always use `"thinking"` when the task is complex or ambiguous. Plan generation typically takes 200-600ms. ### Generate a plan @@ -870,7 +887,7 @@ TENANT_ID = "chief-of-staff" def generate_execution_plan( task: str, user_id: str, - sub_tenant: str = "functions", + collection: str = "functions", max_steps: int = 8, ) -> list: """ @@ -884,12 +901,12 @@ def generate_execution_plan( # Step 1: search candidate functions with thinking mode search = client.query( database=TENANT_ID, - collection=sub_tenant, + collection=collection, query=task, max_results=12, mode="thinking", graph_context=True, - metadata_filters={"deprecated": False}, + attributes={"deprecated": False}, ) chunks = search.data.chunks or [] @@ -898,18 +915,20 @@ def generate_execution_plan( # Build the function catalogue string for the planner fn_catalogue = "\n\n".join( - f"FUNCTION {i+1}: {c.source_title}\n{c.chunk_content[:600]}" + f"FUNCTION {i+1}: {c.context_id}\n{(c.content or '')[:600]}" for i, c in enumerate(chunks) ) # Search user preferences to personalise the plan user_prefs = client.query( - type="memory", database=TENANT_ID, collection=f"user-{user_id}", query="channel preferences urgency communication style", mode="thinking", ) + prefs_text = "\n".join( + c.content or "" for c in (user_prefs.data.chunks or []) + ) # Step 2: use LLM to sequence the plan resp = openai_client.chat.completions.create( @@ -931,7 +950,7 @@ def generate_execution_plan( "role": "user", "content": ( f"Task: {task}\n\n" - f"User preferences: {user_prefs}\n\n" + f"User preferences: {prefs_text}\n\n" f"Available functions:\n{fn_catalogue}" ), }, @@ -1037,7 +1056,7 @@ def execute_plan( ### Rollback & compensation -For destructive or irreversible actions, register a compensation function alongside the main one. If step *n* fails after steps 1–*n-1* have completed, the compensation chain runs in reverse order to undo what it can. Not all actions have meaningful rollbacks - a sent Slack message cannot be unsent. Mark those as `compensatable: false` in their schema metadata. +For destructive or irreversible actions, register a compensation function alongside the main one. If step *n* fails after steps 1-*n-1* have completed, the compensation chain runs in reverse order to undo what it can. Not all actions have meaningful rollbacks - a sent Slack message cannot be unsent. Mark those as `compensatable: false` in their schema metadata. ```python title="planning/rollback.py" @@ -1099,10 +1118,11 @@ The Chief of Staff should react to three types of input: direct commands from us ### Slack slash-command (direct commands) -Expose a Slack slash-command that forwards the user's natural-language instruction directly to the Orchestrator. The Slack user ID maps to the `user_id` used for memory search, so HydraDB already knows this user's preferences and personalizes the function suggestion accordingly. +Expose a Slack slash-command that forwards the user's natural-language instruction directly to the Orchestrator. The Slack user ID maps to the `user_id` used for profile search, so HydraDB already knows this user's preferences and personalizes the function suggestion accordingly. ```python title="triggers/slack_command.py" +import os from flask import Flask, request, jsonify from slack_sdk import WebClient @@ -1164,7 +1184,7 @@ if __name__ == "__main__": ### Scheduled jobs -For recurring tasks - daily standup summaries, weekly metric reports, Monday morning briefings - use a cron-triggered cloud function. The task description is static, but HydraDB's function selection and personalization still apply because the `user_id` carries the recipient's memory and preferences. The CEO's Monday briefing looks different from the CTO's even though both come from the same cron. +For recurring tasks - daily standup summaries, weekly metric reports, Monday morning briefings - use a cron-triggered cloud function. The task description is static, but HydraDB's function selection and personalization still apply because the `user_id` carries the recipient's profile and preferences. The CEO's Monday briefing looks different from the CTO's even though both come from the same cron. ```python title="triggers/scheduled_jobs.py" @@ -1172,7 +1192,7 @@ For recurring tasks - daily standup summaries, weekly metric reports, Monday mor def weekly_executive_briefing(): """ Generate and deliver a personalized weekly briefing to each exec. - HydraDB uses each exec's memory profile to select the right + HydraDB uses each exec's stored profile to select the right report type, channel, and level of detail automatically. """ execs = [ @@ -1220,7 +1240,7 @@ Monitoring alerts, new Jira tickets, CRM stage changes, and GitHub PR events all def handle_monitoring_alert(): """ Receives a monitoring alert and routes it to the correct on-call response. - HydraDB's memory of past incidents and the team's learned response patterns + HydraDB's context graph of past incidents and the team's learned response patterns determine whether this triggers immediate escalation or scheduled review. """ data = request.json @@ -1362,7 +1382,7 @@ When `policy.requires_approval()` returns `True`, route the action through a Sla ```python title="security/approval.py" -import json, os +import json, os, uuid from hydra_db import HydraDB client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) @@ -1384,11 +1404,12 @@ def request_approval( # Store the pending action in HydraDB so it can be resumed after approval client.context.ingest( - type='memory', database=TENANT_ID, collection="approvals", upsert=True, - memories=json.dumps([{ + context=json.dumps([{ + "context_id": f"approval-{approval_id}", + "title": f"Approval {approval_id}: {function_id}", "text": json.dumps({ "approval_id": approval_id, "user_id": user_id, @@ -1398,7 +1419,8 @@ def request_approval( "status": "pending", }), "user_name": "approval-system", - "infer": False, + "enrich": False, + "attributes": {"doc_type": "approval"}, }]), ) @@ -1428,22 +1450,22 @@ def request_approval( ## Observability & Self-Improvement -Track three metrics to understand if the Chief of Staff is working. Feed failures back to HydraDB to close the improvement loop. The system gets measurably better over time - not by manual tuning, but by accumulating execution memory. +Track three metrics to understand if the Chief of Staff is working. Feed failures back to HydraDB to close the improvement loop. The system gets measurably better over time - not by manual tuning, but by accumulating execution context. | Metric | What to measure | Target | Action if below target | | --- | --- | --- | --- | -| Suggestion acceptance rate | % of suggested functions the user actually runs without rejecting | >85% | Improve function descriptions; add more user preference memories | +| Suggestion acceptance rate | % of suggested functions the user actually runs without rejecting | >85% | Improve function descriptions; add more user preference context | | Multi-step plan completion rate | % of generated plans that complete all steps without rollback | >90% | Add compensation functions; fix idempotency issues in executors | | P95 end-to-end latency | Time from task submission to last function execution complete | under 3s single-step, under 15s 5-step plan | Use mode: "fast" for single-function tasks; cache function registry | | Rollback frequency | % of plans that trigger rollback due to mid-plan failure | under 2% | Add retries with back-off; mark flaky functions as optional: true | -| Function routing accuracy | % of tasks where HydraDB's top-1 suggestion matches what the user intended | >90% | Add user_rejected feedback memories; rewrite function descriptions | +| Function routing accuracy | % of tasks where HydraDB's top-1 suggestion matches what the user intended | >90% | Add user_rejected feedback items; rewrite function descriptions | ### Feeding metrics back to HydraDB -Every execution metric is a signal HydraDB can learn from. A consistent `slow_response` signal for `create_calendar_event` eventually influences the plan generator to place that function at the end of plans where it won't block other steps. Routing accuracy below threshold triggers re-examination of the function description and preference memory quality. +Every execution metric is a signal HydraDB can learn from. A consistent `slow_response` signal for `create_calendar_event` eventually influences the plan generator to place that function at the end of plans where it won't block other steps. Routing accuracy below threshold triggers re-examination of the function description and preference context quality. ```python title="observability/metrics.py" @@ -1472,14 +1494,14 @@ def report_function_performance( f"Observed: {datetime.now(timezone.utc).isoformat()}" ) client.context.ingest( - type='memory', database=TENANT_ID, collection="function-performance", upsert=True, - memories=json.dumps([{ - "text": text, - "user_name": "observability-system", - "infer": True, + context=json.dumps([{ + "text": text, + "user_name": "observability-system", + "enrich": True, + "attributes": {"doc_type": "performance_signal"}, }]), ) @@ -1505,7 +1527,7 @@ report_function_performance( 💡 -> **The compound effect.** Every execution memory shifts the routing for the next call. After 500 executions per user, HydraDB has a detailed model of how that person works - which functions they trust, which channels they prefer, which task types they delegate vs. handle personally. The Chief of Staff becomes measurably more useful without any manual configuration. Track suggestion acceptance rate week-over-week as your primary health metric - it should trend upward continuously as memories accumulate. +> **The compound effect.** Every execution context item shifts the routing for the next call. After 500 executions per user, HydraDB has a detailed model of how that person works - which functions they trust, which channels they prefer, which task types they delegate vs. handle personally. The Chief of Staff becomes measurably more useful without any manual configuration. Track suggestion acceptance rate week-over-week as your primary health metric - it should trend upward continuously as context accumulates. ## Complete API Reference @@ -1529,43 +1551,47 @@ Header: `Authorization: Bearer YOUR_API_KEY` ### Upload function schemas -**`POST /context/ingest`** - Upload function schemas as app sources or documents. Max 20/call, 1s between batches +**`POST /context/ingest`** - Upload function schemas as context items. Up to 100 items per call ```json title="body - one function" -[{ - "id": "send_slack_message", - "title": "Send a Slack message", - "type": "function", // tells HydraDB this is callable - "timestamp": "2025-01-01T00:00:00Z", - "content": { "text": "{ full JSON schema as string }" }, - "metadata": { - "type": "function", - "collections": ["communication", "slack"], - "permissions": ["all_users"], - "idempotent": false, - "side_effects": "Sends a visible Slack message. Cannot be unsent.", - "deprecated": false - } -}] +{ + "database": "chief-of-staff", + "collection": "functions", + "upsert": true, + "context": [{ + "context_id": "send_slack_message", + "title": "Send a Slack message", + "text": "{ full JSON schema as string }", + "attributes": { + "doc_type": "function", + "department": "all", + "deprecated": false + }, + "custom_attributes": { + "idempotent": "false", + "side_effects": "Sends a visible Slack message. Cannot be unsent." + } + }] +} ``` ### Search function suggestions (single-step) -**`POST /query`** - Returns top-matched function knowledge objects +**`POST /query`** - Returns top-matched function context items ```json title="body" { "database": "chief-of-staff", - "collection": "functions", // or team-scoped collection + "collection": "functions", "query": "book a 30-min call with alice next tuesday", "max_results": 5, - "mode": "thinking", // multi-query rerank + personalised search - "graph_context": false, // not needed for single function lookup - "metadata_filters": { "deprecated": false } + "mode": "thinking", + "graph_context": false, + "attributes": { "deprecated": false } } ``` @@ -1582,9 +1608,9 @@ Header: `Authorization: Bearer YOUR_API_KEY` "collection": "functions", "query": "Onboard Alex Chen who starts Monday as a backend engineer.", "max_results": 12, - "mode": "thinking", // multi-query decomposition for complex tasks - "graph_context": true, // surfaces function composition chains - "metadata_filters": { "deprecated": false } + "mode": "thinking", + "graph_context": true, + "attributes": { "deprecated": false } } ``` @@ -1598,29 +1624,32 @@ Header: `Authorization: Bearer YOUR_API_KEY` ```json title="body" { "database": "chief-of-staff", - "collection": "user-sarah", // per-user collection + "collection": "user-sarah", "query": "channel preferences urgency communication timing", "mode": "thinking" } ``` -### Store user preference memory +### Store user preference context -**`POST /context/ingest`** - infer: true extracts channel + style signals +**`POST /context/ingest`** - enrich: true extracts channel + style signals ```json title="body" { - "memories": [{ - "text": "Sarah always uses Slack DMs for urgent internal updates, not email.", - "user_name": "sarah", - "infer": true // extracts channel preference signal - }], - "database": "chief-of-staff", + "database": "chief-of-staff", "collection": "user-sarah", - "upsert": true + "upsert": true, + "context": [{ + "context_id": "preferences-sarah", + "title": "sarah preferences", + "text": "Sarah always uses Slack DMs for urgent internal updates, not email.", + "user_name": "sarah", + "enrich": true, + "attributes": {"doc_type": "user_profile"} + }] } ``` @@ -1628,19 +1657,23 @@ Header: `Authorization: Bearer YOUR_API_KEY` ### Store execution outcome (audit log) -**`POST /context/ingest`** - infer: false for verbatim audit records +**`POST /context/ingest`** - enrich: false for verbatim audit records ```json title="body" { - "memories": [{ - "text": "[2025-06-10T09:12:44Z] user=sarah fn=send_slack_message outcome=success latency=180ms", - "user_name": "system", - "infer": false // verbatim audit record - exact facts, no interpretation - }], - "database": "chief-of-staff", + "database": "chief-of-staff", "collection": "execution-log", - "upsert": true + "upsert": true, + "context": [{ + "context_id": "exec-send_slack_message-2025-06-10T09:12:44Z", + "title": "send_slack_message outcome", + "happened_at": "2025-06-10T09:12:44Z", + "text": "user=sarah fn=send_slack_message outcome=success latency=180ms", + "user_name": "system", + "enrich": false, + "attributes": {"doc_type": "execution_outcome"} + }] } ``` @@ -1648,19 +1681,20 @@ Header: `Authorization: Bearer YOUR_API_KEY` ### Store performance signal (self-improvement) -**`POST /context/ingest`** - infer: true so HydraDB links signal to future suggestions +**`POST /context/ingest`** - enrich: true so HydraDB links signal to future suggestions ```json title="body" { - "memories": [{ - "text": "create_calendar_event returned slow_response: p95=2800ms over 412 calls in W22.", - "user_name": "observability-system", - "infer": true // HydraDB links signal to function routing weight - }], - "database": "chief-of-staff", + "database": "chief-of-staff", "collection": "function-performance", - "upsert": true + "upsert": true, + "context": [{ + "text": "create_calendar_event returned slow_response: p95=2800ms over 412 calls in W22.", + "user_name": "observability-system", + "enrich": true, + "attributes": {"doc_type": "performance_signal"} + }] } ``` @@ -1692,13 +1726,13 @@ Header: `Authorization: Bearer YOUR_API_KEY` ## Benchmarks -Tested across 3,200 task executions spanning 48 registered functions and 6 user profiles. Comparison baseline: a standard LLM agent with function-calling and no persistent memory layer, using the same function schemas as tool definitions. +Tested across 3,200 task executions spanning 48 registered functions and 6 user profiles. Comparison baseline: a standard LLM agent with function-calling and no persistent context layer, using the same function schemas as tool definitions. | Metric | Standard LLM function-calling | HydraDB Chief of Staff | Delta | | --- | --- | --- | --- | | Top-1 function routing accuracy (week 1) | 71% | 78% | +10% | -| Top-1 function routing accuracy (week 8, after memory accumulation) | 72% | 93% | +29% | +| Top-1 function routing accuracy (week 8, after context accumulation) | 72% | 93% | +29% | | Multi-step plan completion rate (5-step plans) | 54% | 88% | +63% | | Personalization accuracy (correct channel/timing per user) | 31% | 86% | +177% | | Suggestion acceptance rate (no user rejection) | 68% | 91% | +34% | @@ -1707,11 +1741,11 @@ Tested across 3,200 task executions spanning 48 registered functions and 6 user ℹ️ -> **Benchmark methodology.** Figures are based on internal HydraDB testing. For the formal benchmark paper and methodology, see [research.hydradb.com/hydradb.pdf](https://research.hydradb.com/hydradb.pdf). Results will vary by function library size, description quality, and the volume of execution memory accumulated. +> **Benchmark methodology.** Figures are based on internal HydraDB testing. For the formal benchmark paper and methodology, see [research.hydradb.com/hydradb.pdf](https://research.hydradb.com/hydradb.pdf). Results will vary by function library size, description quality, and the volume of execution context accumulated. ℹ️ -> The jump from 78% to 93% routing accuracy between week 1 and week 8 reflects HydraDB's memory accumulation. In week 1, function selection is purely semantic - it reads descriptions and matches tasks. By week 8, 3,200 execution outcomes have been stored as memory, and HydraDB has learned that Sarah always uses Slack over email, that the engineering team routes alerts to PagerDuty not Jira, and that "prepare for a call" for sales users means checking the CRM first. Standard LLM function-calling stays flat at 72% because it resets every session. +> The jump from 78% to 93% routing accuracy between week 1 and week 8 reflects HydraDB's context accumulation. In week 1, function selection is purely semantic - it reads descriptions and matches tasks. By week 8, 3,200 execution outcomes have been stored as context, and HydraDB has learned that Sarah always uses Slack over email, that the engineering team routes alerts to PagerDuty not Jira, and that "prepare for a call" for sales users means checking the CRM first. Standard LLM function-calling stays flat at 72% because it resets every session. --- diff --git a/cookbooks/v2/index.mdx b/cookbooks/v2/index.mdx index c01db0ea..c1822a99 100644 --- a/cookbooks/v2/index.mdx +++ b/cookbooks/v2/index.mdx @@ -1,11 +1,8 @@ --- title: "Introduction" description: "Production-ready guides for building AI agents with HydraDB." -noindex: true --- -This page is deprecated: it documents the knowledge and memory API, which unified databases replace. See [Ingest context](/essentials/v2/ingest) and [Query](/essentials/v2/query). - Step-by-step tutorials that go from zero to a working agent. Each cookbook uses real HydraDB endpoints, includes copy-paste code, and ends with something you can ship. @@ -19,13 +16,13 @@ Step-by-step tutorials that go from zero to a working agent. Each cookbook uses People search in natural language - find candidates by skills, experience, and fit. - Personalized travel recommendations with persistent user preferences and memory. + Personalized travel recommendations with persistent user preferences. AI assistant that answers "why was this built this way?" from your codebase, PRs, Slack, and RFCs. - Support bot with per-user memory - knows the customer's plan, history, and what already failed. + Support bot with per-user context - knows the customer's plan, history, and what already failed. Conversational search across Notion, Confluence, and Slack with full decision provenance. diff --git a/cookbooks/v2/internal-search-perplexity.mdx b/cookbooks/v2/internal-search-perplexity.mdx index 0006e272..15d1634b 100644 --- a/cookbooks/v2/internal-search-perplexity.mdx +++ b/cookbooks/v2/internal-search-perplexity.mdx @@ -1,16 +1,13 @@ --- title: "Perplexity for Internal Knowledge" description: "Ingest Slack, Gmail, Confluence, GitHub, and Linear into one HydraDB database. Ask any question in natural language and get a cited, synthesized answer drawing from across all your company's knowledge - including 'what led to the decision to sunset Project X?' with full decision provenance." -noindex: true --- -This page is deprecated: it documents the knowledge and memory API, which unified databases replace. See [Ingest context](/essentials/v2/ingest) and [Query](/essentials/v2/query). - This guide walks you through building a **company-wide internal search engine** powered by HydraDB. Unlike per-tool search (Slack search for messages, Confluence search for docs), this agent queries everything simultaneously - Slack threads, email, wikis, code issues, and project management - and synthesizes a single cited answer from across all sources. -> **Note**: All code in this guide is production-ready and uses real HydraDB endpoints. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). +> **Note**: All code in this guide is production-ready and uses real HydraDB endpoints. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com). SDK snippets use the call shapes in the [SDK reference](/api-reference/v2/sdks) and require an SDK release generated from the current API specification. -> **Goal**: Ingest six source types into one HydraDB database, store per-user memory profiles for personalized answers, and answer three query patterns - simple factual lookup, decision provenance, and cross-source synthesis - all through `POST /query`. +> **Goal**: Ingest six source types into one HydraDB database, store per-user context profiles for personalized answers, and answer three query patterns - simple factual lookup, decision provenance, and cross-source synthesis - all through `POST /query`. --- @@ -40,7 +37,7 @@ By the end of this cookbook, you'll be able to: - Ingest Slack threads, Gmail, Confluence pages, GitHub issues, and Linear tickets into a single HydraDB database - Answer cross-source questions like "Why did we move to a monorepo?" that span multiple tools and time periods - Use `recency_bias` and `graph_context: true` to surface the most relevant, connected context across all sources -- Store per-user memory so answers are personalized to each employee's role and project context +- Store per-user context so answers are personalized to each employee's role and project context --- @@ -58,17 +55,17 @@ The critical capability that makes this possible is HydraDB's context graph. It ```mermaid graph LR - A["Slack · Gmail · Confluence
GitHub · Linear · Notion"] -->|"multipart upload"| B["Ingestion Layer
connectors/slack.py
connectors/gmail.py
connectors/confluence.py
connectors/github.py"] + A["Slack · Gmail · Confluence
GitHub · Linear · Notion"] -->|"context items"| B["Ingestion Layer
connectors/slack.py
connectors/gmail.py
connectors/confluence.py
connectors/github.py"] B -->|"POST /context/ingest"| C["HydraDB
database: company-knowledge
collections: slack, email, docs, github"] D["User / Slack bot / Web UI"] -->|"POST /query"| C - C -->|"ranked chunks + graph_context"| D + C -->|"ranked chunks + graph"| D E["POST /context/ingest"] -->|"user profile"| C C -->|"POST /query"| D ``` -- **Ingestion Layer**: Six connector scripts that format source content and upload to HydraDB via `POST /context/ingest` using multipart form-data. +- **Ingestion Layer**: Six connector scripts that format source content into `context` items and upload to HydraDB via `POST /context/ingest`. - **HydraDB**: Stores all sources, automatically builds a context graph linking entities across tools, and ranks results by relevance and recency at query time. -- **User Memory**: Per-user profiles stored via `POST /context/ingest` and retrieved via `POST /query` to personalize answer depth and format. +- **User Context**: Per-user profiles stored via `POST /context/ingest` and retrieved via `POST /query` to personalize answer depth and format. --- @@ -79,8 +76,9 @@ One database for all company knowledge. Use `collection` to isolate by source ty ```bash curl -X POST 'https://api.hydradb.com/databases' \ -H "Authorization: Bearer YOUR_API_KEY" \ + -H "API-Version: 2" \ -H "Content-Type: application/json" \ - -d '{"database": "company-knowledge"}' + -d '{"database": "company-knowledge", "database_metadata_schema": [{"name": "source_type", "data_type": "VARCHAR"}, {"name": "doc_type", "data_type": "VARCHAR"}, {"name": "channel", "data_type": "VARCHAR"}, {"name": "space", "data_type": "VARCHAR"}, {"name": "repo", "data_type": "VARCHAR"}]}' ``` @@ -92,7 +90,21 @@ from hydra_db import HydraDB client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) TENANT_ID = "company-knowledge" -client.databases.create(database=TENANT_ID) +client.databases.create( + database=TENANT_ID, + database_metadata_schema=[ + {"name": "source_type", "data_type": "VARCHAR"}, + {"name": "doc_type", "data_type": "VARCHAR"}, + {"name": "channel", "data_type": "VARCHAR"}, + {"name": "space", "data_type": "VARCHAR"}, + {"name": "repo", "data_type": "VARCHAR"}, + ], +) + +# Database creation is asynchronous - poll until ready before ingesting +import time +while not client.databases.status(database=TENANT_ID).data.infra.ready_for_ingestion: + time.sleep(4) ``` ```typescript TypeScript SDK @@ -102,7 +114,21 @@ import { HydraDBClient } from "@hydradb/sdk"; const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY! }); const TENANT_ID = "company-knowledge"; -await client.databases.create({ database: TENANT_ID }); +await client.databases.create({ + database: TENANT_ID, + databaseMetadataSchema: [ + { name: "source_type", data_type: "VARCHAR" }, + { name: "doc_type", data_type: "VARCHAR" }, + { name: "channel", data_type: "VARCHAR" }, + { name: "space", data_type: "VARCHAR" }, + { name: "repo", data_type: "VARCHAR" }, + ], +}); + +// Database creation is asynchronous - poll until ready before ingesting +while (!(await client.databases.status({ database: TENANT_ID })).data?.infra?.readyForIngestion) { + await new Promise((resolve) => setTimeout(resolve, 4000)); +} ``` @@ -110,32 +136,26 @@ await client.databases.create({ database: TENANT_ID }); ## Step 2 - Ingest Company Knowledge -All connectors use the same endpoint: `POST /context/ingest`. This endpoint uses **multipart form-data** - not JSON. `database` and `collection` are form fields alongside the file. +All connectors use the same endpoint: `POST /context/ingest` with a JSON body. Each source document becomes a `context` item - `{context_id, title, text, happened_at, attributes}` - and each source type gets its own collection (`slack`, `email`, `docs`, `github`) so queries can scope per source. Ingest is asynchronous: it returns `202 Accepted` and queues indexing. -> **Important**: Do not set `Content-Type: application/json`. Pass only `Authorization` in headers and let your HTTP client set the multipart boundary automatically. +> **Important**: There is no file upload on a unified database. Format source content into `text` client-side and send it inside `context` items. -> **Batch limit**: Max 20 sources per request. Wait 1 second between batches. Always call `GET /context/status` before querying newly ingested content. +> **Batch limit**: Up to 100 items per request. Always call `GET /context/status` before querying newly ingested content. -The upload response for all connectors looks like this: +The ingest response for all connectors looks like this: ```json { - "success": true, - "message": "Knowledge uploaded successfully", "results": [ { - "id": "d25fb5a6-0378-4bcb-8cbc-2012c3d12ca2", - "filename": "slack-engineering-2024-11-15.txt", - "status": "queued", - "error": null + "id": "slack-engineering-1699430400.000001", + "status": "accepted" } - ], - "success_count": 1, - "failed_count": 0 + ] } ``` -Save `results[0].id` - you need it to verify indexing. +`results[].id` echoes the `context_id` you set - keep it stable across syncs so `upsert` replaces content instead of duplicating it. ### 2.1 Slack Channels @@ -144,7 +164,7 @@ Combine each thread (parent message + all replies) into one document. HydraDB's ```python Python SDK # connectors/slack.py -import os, time +import json, os, time from slack_sdk import WebClient from datetime import datetime, timezone from hydra_db import HydraDB @@ -157,7 +177,7 @@ slack = WebClient(token=os.environ["SLACK_BOT_TOKEN"]) def ingest_slack_channel(channel_id: str, channel_name: str, days_back: int = 365): """ Ingest messages + threaded replies from a Slack channel. - Each thread becomes one document - the full discussion as a single context unit. + Each thread becomes one context item - the full discussion as a single context unit. """ oldest = str(datetime.now(timezone.utc).timestamp() - days_back * 86400) batch = [] @@ -181,57 +201,75 @@ def ingest_slack_channel(channel_id: str, channel_name: str, days_back: int = 36 thread_text += "\n".join(f"\n↳ {r.get('text','')}" for r in replies) ts_dt = datetime.fromtimestamp(float(msg["ts"]), tz=timezone.utc) - content = ( - f"Source: Slack #{channel_name}\n" - f"Date: {ts_dt.strftime('%Y-%m-%d')}\n\n" - f"{thread_text}" - ) - filename = f"slack-{channel_name}-{msg['ts']}.txt" - - batch.append((filename, content)) + context_id = f"slack-{channel_name}-{msg['ts']}" + batch.append({ + "context_id": context_id, + "title": f"Slack #{channel_name} thread {ts_dt:%Y-%m-%d}", + "happened_at": ts_dt.isoformat(), + "text": ( + f"Source: Slack #{channel_name}\n" + f"Date: {ts_dt.strftime('%Y-%m-%d')}\n\n" + f"{thread_text}" + ), + "attributes": {"source_type": "slack", "channel": channel_name}, + }) - if len(batch) == 20: - all_ids += _upload_batch(batch, "slack") + if len(batch) == 100: + all_ids += _ingest_batch(batch, "slack") batch = [] - time.sleep(1) if not resp["has_more"]: break cursor = resp["response_metadata"]["next_cursor"] if batch: - all_ids += _upload_batch(batch, "slack") + all_ids += _ingest_batch(batch, "slack") print(f"Slack #{channel_name}: {len(all_ids)} threads uploaded") return all_ids -def _upload_batch(batch: list, sub_tenant: str) -> list: - """Upload a batch of (filename, content) tuples as multipart form-data.""" - ids = [] - for filename, content in batch: - result = client.context.ingest( - database=TENANT_ID, - collection=sub_tenant, - documents=[(filename, content.encode("utf-8"), "text/plain")], - ) - items = result.data.results or [] - if items: - ids.append(items[0].id) - time.sleep(0.1) # brief pause between individual uploads in a batch - return ids +def _ingest_batch(batch: list, collection: str) -> list: + """Ingest up to 100 context items in one POST /context/ingest call.""" + result = client.context.ingest( + database=TENANT_ID, + collection=collection, + upsert=True, + context=json.dumps(batch), + ) + return [r.id for r in (result.data.results or [])] ``` -```typescript TypeScript SDK +```typescript TypeScript // connectors/slack.ts -import { HydraDBClient } from "@hydradb/sdk"; -import { readFileSync, writeFileSync } from "fs"; +// REST/fetch (works against the current spec). ingestContext() is reused by every +// connector below - keep it in a shared module, e.g. ./hydradb.ts import { WebClient } from "@slack/web-api"; -const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY! }); +const BASE_URL = "https://api.hydradb.com"; +const HEADERS = { + Authorization: `Bearer ${process.env.HYDRA_DB_API_KEY}`, + "API-Version": "2", + "Content-Type": "application/json", +}; const TENANT_ID = "company-knowledge"; const slack = new WebClient(process.env.SLACK_BOT_TOKEN); +async function ingestContext(collection: string, items: unknown[]) { + const res = await fetch(`${BASE_URL}/context/ingest`, { + method: "POST", + headers: HEADERS, + body: JSON.stringify({ + database: TENANT_ID, + collection, + upsert: true, + context: items, + }), + }); + const json = await res.json(); + return (json.data?.results ?? []).map((r: any) => r.id as string); +} + async function ingestSlackChannel( channelId: string, channelName: string, @@ -239,6 +277,7 @@ async function ingestSlackChannel( ): Promise { const oldest = String(Date.now() / 1000 - daysBack * 86400); const allIds: string[] = []; + let batch: any[] = []; let cursor: string | undefined; while (true) { @@ -263,27 +302,28 @@ async function ingestSlackChannel( .map((r) => `\n↳ ${r.text ?? ""}`) .join(""); } - const tsDate = new Date(Number(msg.ts) * 1000) - .toISOString() - .slice(0, 10); - const content = - `Source: Slack #${channelName}\nDate: ${tsDate}\n\n${threadText}`; - const filename = `/tmp/slack-${channelName}-${msg.ts}.txt`; - writeFileSync(filename, content, "utf-8"); - - const result = await client.context.ingest({ - database: TENANT_ID, - collection: "slack", - documents: [{ data: readFileSync(filename), filename, contentType: "application/octet-stream" }], + const tsDate = new Date(Number(msg.ts) * 1000); + batch.push({ + context_id: `slack-${channelName}-${msg.ts}`, + title: `Slack #${channelName} thread ${tsDate.toISOString().slice(0, 10)}`, + happened_at: tsDate.toISOString(), + text: + `Source: Slack #${channelName}\nDate: ${tsDate.toISOString().slice(0, 10)}\n\n${threadText}`, + attributes: { source_type: "slack", channel: channelName }, }); - const results = result.data?.results ?? []; - if (results[0]?.id) allIds.push(results[0].id); + + if (batch.length === 100) { + allIds.push(...(await ingestContext("slack", batch))); + batch = []; + } } if (!resp.has_more) break; cursor = (resp.response_metadata as any)?.next_cursor; } + if (batch.length) allIds.push(...(await ingestContext("slack", batch))); + console.log(`Slack #${channelName}: ${allIds.length} threads uploaded`); return allIds; } @@ -319,6 +359,7 @@ def ingest_gmail_threads(credentials_path: str, query: str, max_threads: int = 2 threads = results.get("threads", []) all_ids = [] + batch = [] for thread in threads: thread_data = service.users().threads().get(userId="me", id=thread["id"]).execute() messages = thread_data.get("messages", []) @@ -349,30 +390,28 @@ def ingest_gmail_threads(credentials_path: str, query: str, max_threads: int = 2 continue content = f"Source: Gmail\nSubject: {subject}\nDate: {date}\n\n" + "\n\n---\n\n".join(parts) - filename = f"email-{thread['id']}.txt" - result = client.context.ingest( - database=TENANT_ID, - collection="email", - documents=[(filename, content.encode("utf-8"), "text/plain")], - ) - items = result.data.results or [] - if items: - all_ids.append(items[0].id) + batch.append({ + "context_id": f"email-{thread['id']}", + "title": f"Gmail: {subject}", + "text": content, + "attributes": {"source_type": "email"}, + }) - time.sleep(0.2) + if len(batch) == 100: + all_ids += _ingest_batch(batch, "email") # _ingest_batch from connectors/slack.py + batch = [] + + if batch: + all_ids += _ingest_batch(batch, "email") print(f"Gmail: {len(all_ids)} threads uploaded") return all_ids ``` -```typescript TypeScript SDK +```typescript TypeScript // connectors/gmail.ts -import { HydraDBClient } from "@hydradb/sdk"; -import { readFileSync, writeFileSync } from "fs"; - -const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY! }); -const TENANT_ID = "company-knowledge"; +// uses ingestContext() from ./hydradb (defined in connectors/slack.ts above) async function ingestGmailThreads( gmailService: any, @@ -380,6 +419,7 @@ async function ingestGmailThreads( maxThreads: number = 200 ): Promise { const allIds: string[] = []; + let batch: any[] = []; const listResp = await gmailService.users.threads.list({ userId: "me", q: query, @@ -424,18 +464,22 @@ async function ingestGmailThreads( const content = `Source: Gmail\nSubject: ${subject}\nDate: ${date}\n\n` + parts.join("\n\n---\n\n"); - const filename = `/tmp/email-${thread.id}.txt`; - writeFileSync(filename, content, "utf-8"); - const result = await client.context.ingest({ - database: TENANT_ID, - collection: "email", - documents: [{ data: readFileSync(filename), filename, contentType: "application/octet-stream" }], + batch.push({ + context_id: `email-${thread.id}`, + title: `Gmail: ${subject}`, + text: content, + attributes: { source_type: "email" }, }); - const results = result.data?.results ?? []; - if (results[0]?.id) allIds.push(results[0].id); + + if (batch.length === 100) { + allIds.push(...(await ingestContext("email", batch))); + batch = []; + } } + if (batch.length) allIds.push(...(await ingestContext("email", batch))); + console.log(`Gmail: ${allIds.length} threads uploaded`); return allIds; } @@ -466,6 +510,7 @@ def ingest_confluence_space(space_key: str): import requests as req start = 0 all_ids = [] + batch = [] while True: resp = req.get( @@ -491,35 +536,34 @@ def ingest_confluence_space(space_key: str): f"Version: {page['version']['number']}\n\n" f"{text}" ) - filename = f"confluence-{space_key}-{page['id']}.txt" - - result = client.context.ingest( - database=TENANT_ID, - collection="docs", - documents=[(filename, content.encode("utf-8"), "text/plain")], - ) - items = result.data.results or [] - if items: - all_ids.append(items[0].id) - time.sleep(0.1) + batch.append({ + "context_id": f"confluence-{space_key}-{page['id']}", + "title": f"Confluence {space_key}: {page['title']}", + "text": content, + "attributes": {"source_type": "confluence", "space": space_key}, + }) + + if len(batch) == 100: + all_ids += _ingest_batch(batch, "docs") # _ingest_batch from connectors/slack.py + batch = [] if data.get("_links", {}).get("next"): start += 50 else: break + if batch: + all_ids += _ingest_batch(batch, "docs") + print(f"Confluence {space_key}: {len(all_ids)} pages uploaded") return all_ids ``` -```typescript TypeScript SDK +```typescript TypeScript // connectors/confluence.ts -import { HydraDBClient } from "@hydradb/sdk"; -import { readFileSync, writeFileSync } from "fs"; +// uses ingestContext() from ./hydradb (defined in connectors/slack.ts above) import axios from "axios"; -const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY! }); -const TENANT_ID = "company-knowledge"; const CONFLUENCE_URL = process.env.CONFLUENCE_BASE_URL!; const CONFLUENCE_AUTH = { username: process.env.CONFLUENCE_EMAIL!, @@ -529,6 +573,7 @@ const CONFLUENCE_AUTH = { async function ingestConfluenceSpace(spaceKey: string): Promise { let start = 0; const allIds: string[] = []; + let batch: any[] = []; while (true) { const resp = await axios.get(`${CONFLUENCE_URL}/wiki/rest/api/content`, { @@ -549,16 +594,18 @@ async function ingestConfluenceSpace(spaceKey: string): Promise { const content = `Source: Confluence\nSpace: ${spaceKey}\nTitle: ${page.title}\n` + `Version: ${page.version.number}\n\n${text}`; - const filename = `/tmp/confluence-${spaceKey}-${page.id}.txt`; - writeFileSync(filename, content, "utf-8"); - const result = await client.context.ingest({ - database: TENANT_ID, - collection: "docs", - documents: [{ data: readFileSync(filename), filename, contentType: "application/octet-stream" }], + batch.push({ + context_id: `confluence-${spaceKey}-${page.id}`, + title: `Confluence ${spaceKey}: ${page.title}`, + text: content, + attributes: { source_type: "confluence", space: spaceKey }, }); - const results = result.data?.results ?? []; - if (results[0]?.id) allIds.push(results[0].id); + + if (batch.length === 100) { + allIds.push(...(await ingestContext("docs", batch))); + batch = []; + } } if (resp.data._links?.next) { @@ -568,6 +615,8 @@ async function ingestConfluenceSpace(spaceKey: string): Promise { } } + if (batch.length) allIds.push(...(await ingestContext("docs", batch))); + console.log(`Confluence ${spaceKey}: ${allIds.length} pages uploaded`); return allIds; } @@ -599,6 +648,7 @@ def ingest_github_issues(repo_name: str, state: str = "all", limit: int = 500): repo = gh.get_repo(repo_name) sub = repo_name.lower().replace("/", "-") all_ids = [] + batch = [] count = 0 for issue in repo.get_issues(state=state, sort="updated", direction="desc"): @@ -617,30 +667,30 @@ def ingest_github_issues(repo_name: str, state: str = "all", limit: int = 500): f"{issue.body or ''}\n\n" f"Discussion:\n" + "\n\n".join(comments) ) - filename = f"github-{sub}-issue-{issue.number}.txt" + batch.append({ + "context_id": f"github-{sub}-issue-{issue.number}", + "title": f"GitHub {repo_name} issue #{issue.number}: {issue.title}", + "happened_at": issue.created_at.isoformat(), + "text": content, + "attributes": {"source_type": "github", "repo": repo_name}, + }) + + if len(batch) == 100: + all_ids += _ingest_batch(batch, "github") # _ingest_batch from connectors/slack.py + batch = [] - result = client.context.ingest( - database=TENANT_ID, - collection="github", - documents=[(filename, content.encode("utf-8"), "text/plain")], - ) - items = result.data.results or [] - if items: - all_ids.append(items[0].id) - time.sleep(0.1) + if batch: + all_ids += _ingest_batch(batch, "github") print(f"GitHub {repo_name}: {len(all_ids)} issues uploaded") return all_ids ``` -```typescript TypeScript SDK +```typescript TypeScript // connectors/github.ts -import { HydraDBClient } from "@hydradb/sdk"; -import { readFileSync, writeFileSync } from "fs"; +// uses ingestContext() from ./hydradb (defined in connectors/slack.ts above) import { Octokit } from "@octokit/rest"; -const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY! }); -const TENANT_ID = "company-knowledge"; const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); async function ingestGithubIssues( @@ -651,6 +701,7 @@ async function ingestGithubIssues( const [owner, repo] = repoName.split("/"); const sub = repoName.toLowerCase().replace("/", "-"); const allIds: string[] = []; + let batch: any[] = []; let count = 0; for await (const issue of octokit.paginate.iterator( @@ -678,33 +729,38 @@ async function ingestGithubIssues( `Issue #${item.number}: ${item.title}\n` + `State: ${item.state}\nLabels: ${labelList}\n\n` + `${item.body ?? ""}\n\nDiscussion:\n${comments.join("\n\n")}`; - const filename = `/tmp/github-${sub}-issue-${item.number}.txt`; - writeFileSync(filename, content, "utf-8"); - const result = await client.context.ingest({ - database: TENANT_ID, - collection: "github", - documents: [{ data: readFileSync(filename), filename, contentType: "application/octet-stream" }], + batch.push({ + context_id: `github-${sub}-issue-${item.number}`, + title: `GitHub ${repoName} issue #${item.number}: ${item.title}`, + happened_at: item.created_at, + text: content, + attributes: { source_type: "github", repo: repoName }, }); - const results = result.data?.results ?? []; - if (results[0]?.id) allIds.push(results[0].id); + + if (batch.length === 100) { + allIds.push(...(await ingestContext("github", batch))); + batch = []; + } } if (count >= limit) break; } + if (batch.length) allIds.push(...(await ingestContext("github", batch))); + console.log(`GitHub ${repoName}: ${allIds.length} issues uploaded`); return allIds; } ``` -> **Linear connector**: Use the Linear GraphQL API (`https://api.linear.app/graphql`) with your API key. Format each issue + comments as a plain text file with `Source: Linear` prepended, and upload with `collection: "linear"`. The same multipart upload pattern applies. +> **Linear connector**: Use the Linear GraphQL API (`https://api.linear.app/graphql`) with your API key. Format each issue + comments as a `context` item with `Source: Linear` prepended to `text`, and ingest with `collection: "linear"`. The same batching pattern applies. --- ## Step 3 - Verify Indexing -After uploading, poll `GET /context/status` until `indexing_status` is `completed` before running any queries. HydraDB indexes asynchronously - typically 10–30 seconds per file. +After ingesting, poll `GET /context/status` until `indexing_status` is `completed` before running any queries. HydraDB indexes asynchronously - typically 10-30 seconds per item. > **Note**: [`GET /context/status`](/api-reference/v2/endpoint/source-status) takes `ids` and `database` as **query parameters**. Pass multiple `ids` to check a batch in one call. @@ -770,7 +826,6 @@ const TENANT_ID = "company-knowledge"; async function waitUntilIndexed( id: string, - subTenant: string, maxTries: number = 20, intervalMs: number = 3000 ): Promise { @@ -778,7 +833,6 @@ async function waitUntilIndexed( await new Promise((r) => setTimeout(r, intervalMs)); const result = await client.context.status({ database: TENANT_ID, - collection: subTenant, ids: [id], }); const statuses = result.data.statuses ?? []; @@ -800,42 +854,38 @@ async function waitUntilIndexed( --- -## Step 4 - Store User Memory Profiles +## Step 4 - Store User Context Profiles -Each user gets a persistent memory profile. HydraDB uses it to personalize search results - an engineer gets more technical answers with PR citations, a product manager gets decision context and timelines, a new hire gets more background on why things are built the way they are. +Each user gets a persistent profile stored in their own collection (`user-`). HydraDB uses it to personalize search results - an engineer gets more technical answers with PR citations, a product manager gets decision context and timelines, a new hire gets more background on why things are built the way they are. ```bash curl -X POST 'https://api.hydradb.com/context/ingest' \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "memories": [{ - "text": "Alice is a senior engineer on the platform team. She owns the auth service and data pipeline. Prefers technical depth with references to PRs and ADRs.", - "infer": true, - "user_name": "alice" - }], "database": "company-knowledge", "collection": "user-alice", - "upsert": true + "upsert": true, + "context": [{ + "context_id": "profile-alice", + "title": "Alice profile", + "text": "Alice is a senior engineer on the platform team. She owns the auth service and data pipeline. Prefers technical depth with references to PRs and ADRs.", + "user_name": "alice", + "enrich": true, + "attributes": {"doc_type": "user_profile"} + }] }' ``` **Response**: ```json { - "success": true, - "message": "Memories queued for ingestion successfully", "results": [ { - "id": "ddb780a2-354f-4a71-8e1b-5101c91c69ce", - "title": "First Document", - "status": "queued", - "infer": false, - "error": null + "id": "profile-alice", + "status": "accepted" } - ], - "success_count": 1, - "failed_count": 0 + ] } ``` @@ -855,24 +905,26 @@ def store_user_profile(user_id: str, profile_text: str) -> str: Store a user profile for personalized search. user_id: their Slack/email handle - must be consistent across sessions. profile_text: free-text description of their role, expertise, and preferences. - infer: true - HydraDB extracts expertise signals and builds graph links. - Returns: id of the stored memory. + enrich: true - HydraDB extracts expertise signals and builds graph links. + Returns: context_id of the stored profile. """ result = client.context.ingest( - type='memory', database=TENANT_ID, collection=f"user-{user_id}", upsert=True, - memories=json.dumps([{ - "text": profile_text, - "infer": True, - "user_name": user_id, + context=json.dumps([{ + "context_id": f"profile-{user_id}", + "title": f"{user_id} profile", + "text": profile_text, + "user_name": user_id, + "enrich": True, + "attributes": {"doc_type": "user_profile"}, }]), ) items = result.data.results or [] - id = items[0].id if items else None - print(f"Profile stored for {user_id} → id: {id}") - return id + cid = items[0].id if items else None + print(f"Profile stored for {user_id} → context_id: {cid}") + return cid ``` ```typescript TypeScript SDK @@ -890,25 +942,27 @@ async function storeUserProfile( * Store a user profile for personalized search. * userId: their Slack/email handle - must be consistent across sessions. * profileText: free-text description of their role, expertise, and preferences. - * infer: true - HydraDB extracts expertise signals and builds graph links. - * Returns: id of the stored memory. + * enrich: true - HydraDB extracts expertise signals and builds graph links. + * Returns: context_id of the stored profile. */ const result = await client.context.ingest({ - type: 'memory', database: TENANT_ID, collection: `user-${userId}`, upsert: true, - memories: JSON.stringify([ + context: JSON.stringify([ { + context_id: `profile-${userId}`, + title: `${userId} profile`, text: profileText, - infer: true, user_name: userId, + enrich: true, + attributes: { doc_type: "user_profile" }, }, ]), }); const results = result.data?.results ?? []; const id: string | null = results[0]?.id ?? null; - console.log(`Profile stored for ${userId} → id: ${id}`); + console.log(`Profile stored for ${userId} → context_id: ${id}`); return id; } @@ -961,12 +1015,12 @@ client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"]) def search( question: str, user_id: str, - sub_tenant: str = None, # restrict to "slack"|"docs"|"email"|"github" or omit for all + collection: str = None, # restrict to "slack"|"docs"|"email"|"github" or omit for all recency_bias: float = 0.5, max_results: int = 15, ) -> dict: """ - Core search function. Returns chunks + graph_context. + Core search function. Returns chunks + graph + llm_prompt. user_id personalizes answer depth based on stored profile. mode="thinking" enables multi-query reranking automatically. """ @@ -978,7 +1032,7 @@ def search( mode="thinking", alpha=0.5, recency_bias=recency_bias, - **(sub_tenant and {"collection": sub_tenant} or {}), + **(collection and {"collection": collection} or {}), ) @@ -986,10 +1040,9 @@ def print_results(result) -> None: chunks = result.data.chunks or [] print(f"\n{len(chunks)} chunks retrieved:\n") for chunk in chunks: - fname = chunk.additional_metadata or {} - score = chunk.relevancy_score or 0 - print(f" [{fname.get('filename', 'memory')} - {score:.2f}]") - print(f" {(chunk.chunk_content or '')[:200]}...") + score = chunk.score or 0 + print(f" [{chunk.context_id} - {score:.2f}]") + print(f" {(chunk.content or '')[:200]}...") print() @@ -1011,12 +1064,12 @@ const TENANT_ID = "company-knowledge"; async function search( question: string, userId: string, - subTenant?: string, + collection?: string, recencyBias: number = 0.5, maxResults: number = 15 ): Promise { /** - * Core search function. Returns chunks + graph_context. + * Core search function. Returns chunks + graph + llm_prompt. * userId personalizes answer depth based on stored profile. * mode="thinking" enables multi-query reranking automatically. */ @@ -1029,7 +1082,7 @@ async function search( alpha: 0.5, recencyBias: recencyBias, }; - if (subTenant) payload["collection"] = subTenant; + if (collection) payload["collection"] = collection; return await client.query(payload); } @@ -1038,10 +1091,9 @@ function printResults(result: any): void { const chunks = result?.data?.chunks ?? []; console.log(`\n${chunks.length} chunks retrieved:\n`); for (const chunk of chunks) { - const fname = chunk?.additional_metadata?.filename ?? "memory"; - const score = chunk?.relevancy_score ?? 0; - console.log(` [${fname} - ${score.toFixed(2)}]`); - console.log(` ${(chunk.chunk_content as string).slice(0, 200)}...`); + const score = chunk?.score ?? 0; + console.log(` [${chunk?.context_id ?? "context"} - ${score.toFixed(2)}]`); + console.log(` ${(chunk?.content ?? "").slice(0, 200)}...`); console.log(); } } @@ -1057,7 +1109,7 @@ printResults(result); ### 5.2 Decision Provenance - "Why did we decide X?" / "What led to Y?" -For provenance questions, use `graph_context: true` and read `graph_context.chunk_relations` from the response - these are the multi-hop entity chains that trace a decision back through Slack, email, Confluence, and GitHub. Pass the chunks and relation chains to an LLM to synthesize a fully cited answer. +For provenance questions, use `graph_context: true` and read `data.graph` from the response - each entry carries `path_summary` and `triplets`, the multi-hop entity chains that trace a decision back through Slack, email, Confluence, and GitHub. Pass `data.llm_prompt` plus the graph paths to an LLM to synthesize a fully cited answer. ```python Python SDK @@ -1072,9 +1124,8 @@ openai_client = OpenAI() def get_user_profile(user_id: str) -> str: - """Retrieve a user's stored memory profile via /query with type: "memory".""" + """Retrieve a user's stored profile via /query against their user- collection.""" result = client.query( - type="memory", database=TENANT_ID, collection=f"user-{user_id}", query="expertise background role preferences", @@ -1082,19 +1133,19 @@ def get_user_profile(user_id: str) -> str: ) chunks = result.data.chunks or [] if chunks: - return chunks[0].chunk_content or "" + return chunks[0].content or "" return "" def explain_decision(question: str, user_id: str) -> str: """ Answer 'why' / 'what led to' questions with full provenance. - Step 1: search chunks + graph relations from /query (type="knowledge"). - Step 2: retrieve user profile from /query (type="memory"). + Step 1: search chunks + graph paths from /query. + Step 2: retrieve user profile from /query. Step 3: synthesize with citations via LLM. """ # Step 1: Search with graph context - data = client.query( + data = client.query( database=TENANT_ID, query=question, max_results=18, @@ -1102,20 +1153,19 @@ def explain_decision(question: str, user_id: str) -> str: mode="thinking", recency_bias=0.4, # low = surfaces both old and recent for decision trails ) - chunks = data.data.chunks or [] - chunk_relations = (data.data.graph_context.chunk_relations if data.data.graph_context else []) + chunks = data.data.chunks or [] + graph = data.data.graph or [] # Build context with source attribution ctx_parts = [] for c in chunks: - fname = (c.additional_metadata or {}).get("filename", "unknown source") - score = c.relevancy_score or 0 - ctx_parts.append(f"[{fname} | score:{score:.2f}]\n{c.chunk_content or ''}") + score = c.score or 0 + ctx_parts.append(f"[{c.context_id} | score:{score:.2f}]\n{c.content or ''}") - for rel in chunk_relations[:6]: - combined = rel.get("combined_context", "") - if combined: - ctx_parts.append(f"[Entity relationship]: {combined}") + for path in graph[:6]: + summary = path.get("path_summary") if isinstance(path, dict) else getattr(path, "path_summary", "") + if summary: + ctx_parts.append(f"[Entity relationship]: {summary}") # Step 2: Get user profile for answer calibration profile = get_user_profile(user_id) @@ -1170,16 +1220,15 @@ const TENANT_ID = "company-knowledge"; const openai = new OpenAI(); async function getUserProfile(userId: string): Promise { - /** Retrieve a user's stored memory profile via recallPreferences. */ + /** Retrieve a user's stored profile via /query against their user- collection. */ const resp = await client.query({ - type: "memory", database: TENANT_ID, collection: `user-${userId}`, query: "expertise background role preferences", mode: "thinking", }); const chunks = resp.data?.chunks ?? []; - return chunks[0]?.chunk_content ?? ""; + return chunks[0]?.content ?? ""; } async function explainDecision( @@ -1188,8 +1237,8 @@ async function explainDecision( ): Promise { /** * Answer 'why' / 'what led to' questions with full provenance. - * Step 1: search chunks + graph relations from fullRecall. - * Step 2: retrieve user profile from recallPreferences. + * Step 1: search chunks + graph paths from /query. + * Step 2: retrieve user profile from /query. * Step 3: synthesize with citations via LLM. */ // Step 1: Search with graph context @@ -1202,20 +1251,17 @@ async function explainDecision( recencyBias: 0.4, }); const chunks = data.data?.chunks ?? []; - const chunkRelations = - data.data?.graphContext?.chunkRelations ?? []; + const graph = data.data?.graph ?? []; // Build context with source attribution const ctxParts: string[] = []; for (const c of chunks) { - const fname = - (c.additional_metadata as any)?.filename ?? "unknown source"; - const score = c.relevancy_score ?? 0; - ctxParts.push(`[${fname} | score:${score.toFixed(2)}]\n${c.chunk_content}`); + const score = c.score ?? 0; + ctxParts.push(`[${c.context_id} | score:${score.toFixed(2)}]\n${c.content}`); } - for (const rel of chunkRelations.slice(0, 6)) { - const combined = rel.combined_context ?? ""; - if (combined) ctxParts.push(`[Entity relationship]: ${combined}`); + for (const path of graph.slice(0, 6)) { + const summary = path?.path_summary ?? ""; + if (summary) ctxParts.push(`[Entity relationship]: ${summary}`); } // Step 2: Get user profile for answer calibration @@ -1288,7 +1334,7 @@ def smart_search(question: str, user_id: str) -> dict: ## Step 6 - Search User Preferences -To personalize any answer, retrieve the user's stored memory profile before calling the LLM. This is the same response structure as any [`/query`](/api-reference/v2/endpoint/query) call - an array of `chunks`. +To personalize any answer, retrieve the user's stored profile before calling the LLM. This is the same response structure as any [`/query`](/api-reference/v2/endpoint/query) call - an array of `chunks`. ```bash curl -X POST 'https://api.hydradb.com/query' \ @@ -1305,21 +1351,25 @@ curl -X POST 'https://api.hydradb.com/query' \ **Response**: ```json { - "chunks": [ - { - "chunk_uuid": "0726e63e-e818-4515-88fc-ffbe3b1b523f_chunk_0", - "id": "0726e63e-e818-4515-88fc-ffbe3b1b523f", - "chunk_content": "Alice is a senior engineer on the platform team. She owns the auth service and data pipeline. Prefers technical depth with references to PRs and ADRs.", - "relevancy_score": 0.634, - "additional_metadata": null - } - ], - "sources": [...], - "graph_context": {"query_paths": [], "chunk_relations": [], "chunk_id_to_group_ids": {}} + "data": { + "chunks": [ + { + "chunk_id": "profile-alice_chunk_0", + "context_id": "profile-alice", + "score": 0.634, + "content": "Alice is a senior engineer on the platform team. She owns the auth service and data pipeline. Prefers technical depth with references to PRs and ADRs.", + "enrichment": null, + "temporal": null + } + ], + "graph": [], + "forceful_relations": [], + "llm_prompt": "[1] Alice is a senior engineer on the platform team. She owns the auth service and data pipeline. Prefers technical depth with references to PRs and ADRs." + } } ``` -> **Reading the response**: [`/query`](/api-reference/v2/endpoint/query) returns the same structure regardless of `type` - read the profile from `chunks[0].chunk_content`. `additional_metadata` will be `null` for memory entries; that's expected. +> **Reading the response**: [`/query`](/api-reference/v2/endpoint/query) returns `data.chunks`, `data.graph`, `data.forceful_relations`, and `data.llm_prompt` - read the profile from `data.chunks[0].content`. --- @@ -1381,7 +1431,7 @@ def handle_search_mention(event, client): result = smart_search(question, user_id) answer = result.get("answer") or "\n\n".join( - (c.chunk_content or "")[:300] for c in (result.get("chunks") or [])[:3] + (c.content or "")[:300] for c in (result.get("chunks") or [])[:3] ) # Truncate for Slack (3000 char limit) @@ -1394,7 +1444,7 @@ def handle_search_mention(event, client): ## Step 8 - Incremental Sync -Run a nightly sync to keep all sources current. HydraDB's upload is idempotent - re-uploading unchanged content with the same filename overwrites cleanly. +Run a nightly sync to keep all sources current. Ingest is idempotent - re-ingesting with the same `context_id` and `upsert: true` replaces the item cleanly. ```python # sync/nightly.py @@ -1456,43 +1506,66 @@ All endpoints used in this cookbook. Base URL: `https://api.hydradb.com` · Head | Method | Endpoint | Purpose | |--------|----------|---------| | `POST` | `/databases` | Create the company-knowledge database | -| `POST` | `/context/ingest` | Upload a source file (multipart form-data) | +| `POST` | `/context/ingest` | Ingest source context items (JSON) | | `GET` | `/context/status?database=...&ids=...` | Check indexing status | -| `POST` | `/context/ingest` | Store a user profile memory | +| `POST` | `/context/ingest` | Store a user profile | | `POST` | `/query` | Retrieve user profile for personalization | | `POST` | `/query` | Query all indexed knowledge | ### Create Database ```json -{ "database": "company-knowledge" } +{ + "database": "company-knowledge", + "database_metadata_schema": [ + {"name": "source_type", "data_type": "VARCHAR"}, + {"name": "doc_type", "data_type": "VARCHAR"}, + {"name": "channel", "data_type": "VARCHAR"}, + {"name": "space", "data_type": "VARCHAR"}, + {"name": "repo", "data_type": "VARCHAR"} + ] +} ``` -### Upload Knowledge (form-data) +### Ingest Knowledge -> Do not use `Content-Type: application/json`. This is a multipart upload. +```json +{ + "database": "company-knowledge", + "collection": "slack", + "upsert": true, + "context": [ + { + "context_id": "slack-engineering-1699430400.000001", + "title": "Slack #engineering thread 2024-11-08", + "happened_at": "2024-11-08T00:00:00Z", + "text": "Source: Slack #engineering\n\n", + "attributes": {"source_type": "slack", "channel": "engineering"} + } + ] +} +``` -| Form field | Type | Value | -|---|---|---| -| `database` | Text | `company-knowledge` | -| `collection` | Text | `slack` / `email` / `docs` / `github` | -| `documents` | File | your `.txt` file | +`collection` is `slack` / `email` / `docs` / `github` / `linear` depending on the source. Up to 100 items per request. ### Verify Processing (query params) ``` -GET /context/status?database=company-knowledge&ids=YOUR_ID +GET /context/status?database=company-knowledge&ids=YOUR_CONTEXT_ID ``` -### Store User Memory +### Store User Profile ```json { - "memories": [{ - "text": "Alice is a senior engineer...", - "infer": true, - "user_name": "alice" - }], - "database": "company-knowledge", + "database": "company-knowledge", "collection": "user-alice", - "upsert": true + "upsert": true, + "context": [{ + "context_id": "profile-alice", + "title": "Alice profile", + "text": "Alice is a senior engineer...", + "user_name": "alice", + "enrich": true, + "attributes": {"doc_type": "user_profile"} + }] } ``` @@ -1544,7 +1617,7 @@ Tested across a 2-year company knowledge base: 12 Slack channels, 3 Gmail accoun | Decision provenance ("why did we X?") | 18% | 29% | 82% | **+183%** | | Cross-source synthesis | 8% | 34% | 79% | **+132%** | | New hire onboarding questions | 31% | 48% | 88% | **+83%** | -| Time saved per complex question | 45 min (manual) | ~8 min | under 30 sec | **−94%** | +| Time saved per complex question | 45 min (manual) | ~8 min | under 30 sec | **-94%** | | P95 query latency | N/A (manual) | 220ms | under 200 ms | **Sub-second** | > The 183% improvement on decision provenance reflects HydraDB's context graph. Naive RAG treats a Slack thread, a Confluence page, and a GitHub issue as three isolated vectors. HydraDB understands they are three pieces of the same decision trail - entity-linked across sources - and surfaces all three together with the relationship chain that connects them. @@ -1601,7 +1674,7 @@ atlassian-python-api 1. Run `setup.py` to create your database. 2. Start with one source - ingest a single Slack channel or Confluence space and verify indexing. -3. Store profiles for 2–3 users via `memory/profiles.py`. +3. Store profiles for 2-3 users via `memory/profiles.py`. 4. Run `python search/qa.py` with a real question to confirm results. 5. Wire `search/synthesis.py` into `interfaces/slack_search.py` and deploy the Slack bot. 6. Schedule `sync/nightly.py` via cron once the initial ingest is complete. @@ -1614,5 +1687,6 @@ The search quality improves as more sources are indexed - each new Slack channel | Version | Date | Notes | |---|---|---| +| 1.2 | 2026-06-04 | Rewritten for the unified context API: file uploads replaced with `context` items, memory profiles stored as enriched context in per-user collections, and all responses read `data.chunks` / `data.graph` / `data.llm_prompt`. | | 1.1 | 2026-05-14 | Added TypeScript SDK tabs (CodeGroup) for all HydraDB API call blocks: database creation, all four connector uploads (Slack, Gmail, Confluence, GitHub), verify processing, add memory, full search (factual + provenance), and search preferences. | | 1.0 | 2026-05-09 | Initial release. Python SDK + curl examples for all six source connectors. | diff --git a/docs.json b/docs.json index a4f38175..c728013e 100644 --- a/docs.json +++ b/docs.json @@ -97,6 +97,30 @@ } ] }, + { + "tab": "Cookbooks", + "groups": [ + { + "group": "Cookbooks", + "public": true, + "pages": [ + "cookbooks/v2/index", + "cookbooks/v2/glean-clone", + "cookbooks/v2/ai-chief-of-staff", + "cookbooks/v2/ai-linkedin-recruiter", + "cookbooks/v2/ai-travel-planner", + "cookbooks/v2/cookbook-01-build-cursor-for-docs", + "cookbooks/v2/customer-support-agent", + "cookbooks/v2/cookbook-04-build-notion-ai", + "cookbooks/v2/competitive-intelligence-agent", + "cookbooks/v2/hydradb-cookbook-06", + "cookbooks/v2/internal-search-perplexity", + "cookbooks/v2/ai-onboarding-agent", + "cookbooks/v2/cookbook-10-ai-financial-analyst" + ] + } + ] + }, { "tab": "API Reference", "groups": [ diff --git a/essentials/v2/ingest.mdx b/essentials/v2/ingest.mdx index 8db5a632..f5cd4d76 100644 --- a/essentials/v2/ingest.mdx +++ b/essentials/v2/ingest.mdx @@ -7,7 +7,7 @@ Everything you put into HydraDB is a piece of **context**: a text, or a conversa --- -## 1. Send context +## 1. Ingest context `POST /context/ingest` takes a list called `context`. Each entry is either a `text` or a `conversation`, never both. One request can carry both kinds. diff --git a/get-started/v2/introduction.mdx b/get-started/v2/introduction.mdx index d29406fb..f2b5f78d 100644 --- a/get-started/v2/introduction.mdx +++ b/get-started/v2/introduction.mdx @@ -11,7 +11,7 @@ HydraDB is a unified context substrate for your AI. The brain behind your AI. On - **Business knowledge.** What your company knows: documents, policies, and the tools you connect. - **Decision traces.** What your agents and teams decided, and why. -You ingest it as context into one database and ask one query. HydraDB builds a context graph across all three and returns useful context, personalized for each user. +You ingest all your context into one database and ask one query. HydraDB builds a context graph across all three and returns useful context, personalized for each user. ## The problem we're solving @@ -70,4 +70,4 @@ For enterprise onboarding, contact [founders@hydradb.com](mailto:founders@hydrad ## For AI agents -For AI coding agents and IDE assistants, use the [HydraDB Agent Integration Guide](/AGENTS) and the [v2 OpenAPI spec](/api-reference/v2/openapi.json). Ingest a `context` list and query it with `POST /query`; the query returns `llm_prompt`, ready to inject. \ No newline at end of file +For AI coding agents and IDE assistants, use the [HydraDB Agent Integration Guide](/AGENTS) and the [v2 OpenAPI spec](/api-reference/v2/openapi.json). Ingest a `context` list and query it with `POST /query`; the query returns `llm_prompt`, ready to inject.