Compare commits

..

23 Commits

Author SHA1 Message Date
Ruslan Bakiev
07ffd95a02 Use strict OpenRouter schema for analysis
All checks were successful
Build and deploy Worker / build (push) Successful in 2m44s
2026-05-14 22:10:31 +07:00
Ruslan Bakiev
842d151a44 Request JSON analysis from OpenRouter
Some checks failed
Build and deploy Worker / build (push) Has been cancelled
2026-05-14 22:06:10 +07:00
Ruslan Bakiev
9642bffd16 Use DeepSeek Flash for voice analysis
All checks were successful
Build and deploy Worker / build (push) Successful in 6m25s
2026-05-14 20:03:01 +07:00
Ruslan Bakiev
f59a730169 Retry CI image push
All checks were successful
Build and deploy Worker / build (push) Successful in 1m43s
2026-05-14 08:54:12 +07:00
Ruslan Bakiev
53a7cb7cc8 Add Deepgram and OpenRouter voice processing
Some checks failed
Build and deploy Worker / build (push) Failing after 6m46s
2026-05-14 08:44:20 +07:00
Ruslan Bakiev
f6caaac28f Trigger Dokploy from workflow secret
All checks were successful
Build and deploy Worker / build (push) Successful in 33s
2026-05-13 14:59:28 +07:00
Ruslan Bakiev
a8831e5581 Verify deploy hook
All checks were successful
Build and deploy Worker / build (push) Successful in 9s
2026-05-13 14:52:44 +07:00
Ruslan Bakiev
d18802304a Remove Dokploy webhook from workflow
All checks were successful
Build and deploy Worker / build (push) Successful in 33s
2026-05-13 14:34:09 +07:00
Ruslan Bakiev
4f6cde166e Use latest tag for worker deploys
All checks were successful
Build and deploy Worker / build (push) Successful in 24s
2026-05-09 15:02:23 +07:00
Ruslan Bakiev
ddb276b3f1 Fail worker deploy on webhook errors
All checks were successful
Build and deploy Worker / build (push) Successful in 22s
2026-05-09 14:48:56 +07:00
Ruslan Bakiev
12f9562dbc Deploy worker through Dokploy webhook
All checks were successful
Build and deploy Worker / build (push) Successful in 30s
2026-05-09 14:44:03 +07:00
Ruslan Bakiev
f781845ec7 Use shared builder for worker builds
All checks were successful
Build and deploy Worker / build (push) Successful in 8m27s
2026-05-08 18:31:12 +07:00
Ruslan Bakiev
2816252e6c Load Vault before worker workflows
All checks were successful
Build and deploy Worker / build (push) Successful in 1m0s
2026-05-08 17:16:30 +07:00
Ruslan Bakiev
5b9b2a71d7 Use build-time Prisma database URL
All checks were successful
Build and deploy Worker / build (push) Successful in 1m13s
2026-05-08 17:09:39 +07:00
Ruslan Bakiev
b231eb6a27 Load worker secrets from Vault
Some checks failed
Build and deploy Worker / build (push) Failing after 23s
2026-05-08 16:57:41 +07:00
Ruslan Bakiev
a6203bde71 Sync user relation in Prisma schema
All checks were successful
Build and deploy Worker / build (push) Successful in 51s
2026-05-08 16:44:32 +07:00
Ruslan Bakiev
2d305aec98 Rename ontology intent axis
All checks were successful
Build and deploy Worker / build (push) Successful in 51s
2026-05-08 16:22:36 +07:00
Ruslan Bakiev
dc26274cf8 Fix Prisma client path in container
All checks were successful
Build and deploy Worker / build (push) Successful in 37s
2026-05-08 14:11:15 +07:00
Ruslan Bakiev
97b00e08fb Retry worker deployment
All checks were successful
Build and deploy Worker / build (push) Successful in 2m13s
2026-05-08 14:02:15 +07:00
Ruslan Bakiev
0997ef7f91 Configure registry auth for Gitea builds
Some checks failed
Build and deploy Worker / build (push) Failing after 1m43s
2026-05-08 13:57:47 +07:00
Ruslan Bakiev
a4f9b5b526 Use Gitea job token for registry
Some checks failed
Build and deploy Worker / build (push) Failing after 8s
2026-05-08 13:53:41 +07:00
Ruslan Bakiev
eeec5e3f8a Trigger deployment
Some checks failed
Build and deploy Worker / build (push) Failing after 10s
2026-05-08 13:47:56 +07:00
Ruslan Bakiev
ada03ac070 Add Gitea deployment workflow
Some checks failed
Build and deploy Worker / build (push) Failing after 9m53s
2026-05-08 12:19:51 +07:00
15 changed files with 2685 additions and 185 deletions

View File

@@ -0,0 +1,60 @@
name: Build and deploy Worker
on:
push:
branches:
- main
jobs:
build:
runs-on: builder
env:
SERVICE_NAME: worker
IMAGE_SHA: gitea.dsrptlab.com/mapflow/worker:${{ github.sha }}
IMAGE_LATEST: gitea.dsrptlab.com/mapflow/worker:latest
steps:
- uses: actions/checkout@v4
- name: Configure Gitea registry auth
run: |
set -euo pipefail
mkdir -p ~/.docker
auth="$(printf '%s:%s' "${{ secrets.REGISTRY_USERNAME }}" "${{ secrets.REGISTRY_TOKEN }}" | base64 | tr -d '\n')"
printf '{"auths":{"gitea.dsrptlab.com":{"auth":"%s"}}}\n' "$auth" > ~/.docker/config.json
- name: Build and push image
run: |
set -euo pipefail
for attempt in 1 2 3; do
if docker buildx build --push --provenance=false --tag "$IMAGE_SHA" --tag "$IMAGE_LATEST" .; then
exit 0
fi
sleep "$((attempt * 10))"
done
exit 1
- name: Skip stale deployment
run: |
set -euo pipefail
latest_sha="$(git ls-remote origin refs/heads/main | awk '{print $1}')"
if [ "$latest_sha" = "${GITHUB_SHA}" ]; then
touch .deploy-current
else
echo "A newer main commit exists: $latest_sha. Skipping deploy for ${GITHUB_SHA}."
fi
- name: Trigger Dokploy deploy webhook
run: |
set -euo pipefail
[ -f .deploy-current ] || exit 0
payload=$(cat <<JSON
{"ref":"refs/heads/main","after":"$GITHUB_SHA","commits":[{"id":"$GITHUB_SHA","message":"$SERVICE_NAME #${GITHUB_RUN_NUMBER:-0} ${GITHUB_SHA:0:7}","modified":["Dockerfile"]}]}
JSON
)
response_file="$(mktemp)"
status_code="$(curl -sS -o "$response_file" -w "%{http_code}" -X POST "${{ secrets.DOKPLOY_DEPLOY_WEBHOOK }}" \
-H "x-gitea-event: push" \
-H "Content-Type: application/json" \
-d "$payload")"
cat "$response_file"
[ "$status_code" = "200" ]

View File

@@ -5,10 +5,9 @@ RUN npm ci
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
ENV DATABASE_URL="postgresql://mapflow:mapflow@localhost:5432/mapflow"
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npm run prisma:generate RUN DATABASE_URL="postgresql://mapflow:mapflow@localhost:5432/mapflow" npm run prisma:generate
RUN npm run build RUN npm run build
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
@@ -17,7 +16,7 @@ ENV NODE_ENV=production
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY --from=build /app/src/generated ./src/generated COPY --from=build /app/src/generated ./dist/generated
COPY --from=build /app/prisma ./prisma COPY --from=build /app/prisma ./prisma
COPY --from=build /app/prisma.config.ts ./prisma.config.ts COPY --from=build /app/prisma.config.ts ./prisma.config.ts
CMD ["node", "dist/hatchet/worker.js"] CMD ["node", "dist/hatchet/worker.js"]

View File

@@ -17,25 +17,46 @@ enum VoiceExperienceStatus {
} }
model Place { model Place {
id String @id @default(cuid()) id String @id @default(cuid())
googlePlaceId String @unique googlePlaceId String @unique
name String name String
latitude Float latitude Float
longitude Float longitude Float
experiences VoiceExperience[] googlePrimaryType String?
createdAt DateTime @default(now()) googleTypes String[] @default([])
updatedAt DateTime @updatedAt experiences VoiceExperience[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(cuid())
telegramId String @unique
username String?
firstName String?
lastName String?
photoUrl String?
languageCode String?
isAdmin Boolean @default(false)
voiceExperiences VoiceExperience[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model VoiceExperience { model VoiceExperience {
id String @id @default(cuid()) id String @id @default(cuid())
placeId String placeId String
place Place @relation(fields: [placeId], references: [id]) place Place @relation(fields: [placeId], references: [id])
durationSeconds Int userId String?
audioObjectKey String user User? @relation(fields: [userId], references: [id])
status VoiceExperienceStatus @default(UPLOADED) durationSeconds Int
transcript String? audioObjectKey String
analysis Json? audioContentBase64 String?
createdAt DateTime @default(now()) audioMimeType String?
updatedAt DateTime @updatedAt audioAccessToken String? @unique
status VoiceExperienceStatus @default(UPLOADED)
transcript String?
analysis Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }

View File

@@ -1,7 +1,18 @@
import 'dotenv/config'; import 'dotenv/config';
import { loadVaultEnvironment } from './vault/env.js';
await loadVaultEnvironment();
export const config = { export const config = {
databaseUrl: process.env.DATABASE_URL ?? '', databaseUrl: process.env.DATABASE_URL ?? '',
workerName: process.env.HATCHET_WORKER_NAME ?? 'mapflow-hatchet-worker', workerName: process.env.HATCHET_WORKER_NAME ?? 'mapflow-hatchet-worker',
workerSlots: Number.parseInt(process.env.HATCHET_WORKER_SLOTS ?? '4', 10), workerSlots: Number.parseInt(process.env.HATCHET_WORKER_SLOTS ?? '4', 10),
publicApiUrl: process.env.PUBLIC_API_URL ?? 'https://api.map.craftee.vn',
deepgramApiKey: process.env.DEEPGRAM_API_KEY ?? '',
deepgramModel: process.env.DEEPGRAM_MODEL ?? 'nova-3',
deepgramLanguage: process.env.DEEPGRAM_LANGUAGE ?? 'ru',
openRouterApiKey: process.env.OPENROUTER_API_KEY ?? '',
openRouterModel:
process.env.OPENROUTER_MODEL ?? 'deepseek/deepseek-v4-flash',
}; };

File diff suppressed because one or more lines are too long

View File

@@ -126,6 +126,21 @@ exports.Prisma.PlaceScalarFieldEnum = {
name: 'name', name: 'name',
latitude: 'latitude', latitude: 'latitude',
longitude: 'longitude', longitude: 'longitude',
googlePrimaryType: 'googlePrimaryType',
googleTypes: 'googleTypes',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.UserScalarFieldEnum = {
id: 'id',
telegramId: 'telegramId',
username: 'username',
firstName: 'firstName',
lastName: 'lastName',
photoUrl: 'photoUrl',
languageCode: 'languageCode',
isAdmin: 'isAdmin',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
}; };
@@ -133,8 +148,12 @@ exports.Prisma.PlaceScalarFieldEnum = {
exports.Prisma.VoiceExperienceScalarFieldEnum = { exports.Prisma.VoiceExperienceScalarFieldEnum = {
id: 'id', id: 'id',
placeId: 'placeId', placeId: 'placeId',
userId: 'userId',
durationSeconds: 'durationSeconds', durationSeconds: 'durationSeconds',
audioObjectKey: 'audioObjectKey', audioObjectKey: 'audioObjectKey',
audioContentBase64: 'audioContentBase64',
audioMimeType: 'audioMimeType',
audioAccessToken: 'audioAccessToken',
status: 'status', status: 'status',
transcript: 'transcript', transcript: 'transcript',
analysis: 'analysis', analysis: 'analysis',
@@ -157,16 +176,16 @@ exports.Prisma.QueryMode = {
insensitive: 'insensitive' insensitive: 'insensitive'
}; };
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
};
exports.Prisma.JsonNullValueFilter = { exports.Prisma.JsonNullValueFilter = {
DbNull: Prisma.DbNull, DbNull: Prisma.DbNull,
JsonNull: Prisma.JsonNull, JsonNull: Prisma.JsonNull,
AnyNull: Prisma.AnyNull AnyNull: Prisma.AnyNull
}; };
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
};
exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = { exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
UPLOADED: 'UPLOADED', UPLOADED: 'UPLOADED',
TRANSCRIBING: 'TRANSCRIBING', TRANSCRIBING: 'TRANSCRIBING',
@@ -178,6 +197,7 @@ exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
exports.Prisma.ModelName = { exports.Prisma.ModelName = {
Place: 'Place', Place: 'Place',
User: 'User',
VoiceExperience: 'VoiceExperience' VoiceExperience: 'VoiceExperience'
}; };

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{ {
"name": "prisma-client-5316d8e66293a4954be6e26169692366997bf25fe9186e98c87e62eed3dc691e", "name": "prisma-client-404088dc715856cb4ed2e7f2d4b8eb35c87427ce32c0e67bc04d6ff67961fc2b",
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "default.js", "browser": "default.js",

View File

@@ -17,25 +17,46 @@ enum VoiceExperienceStatus {
} }
model Place { model Place {
id String @id @default(cuid()) id String @id @default(cuid())
googlePlaceId String @unique googlePlaceId String @unique
name String name String
latitude Float latitude Float
longitude Float longitude Float
experiences VoiceExperience[] googlePrimaryType String?
createdAt DateTime @default(now()) googleTypes String[] @default([])
updatedAt DateTime @updatedAt experiences VoiceExperience[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(cuid())
telegramId String @unique
username String?
firstName String?
lastName String?
photoUrl String?
languageCode String?
isAdmin Boolean @default(false)
voiceExperiences VoiceExperience[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model VoiceExperience { model VoiceExperience {
id String @id @default(cuid()) id String @id @default(cuid())
placeId String placeId String
place Place @relation(fields: [placeId], references: [id]) place Place @relation(fields: [placeId], references: [id])
durationSeconds Int userId String?
audioObjectKey String user User? @relation(fields: [userId], references: [id])
status VoiceExperienceStatus @default(UPLOADED) durationSeconds Int
transcript String? audioObjectKey String
analysis Json? audioContentBase64 String?
createdAt DateTime @default(now()) audioMimeType String?
updatedAt DateTime @updatedAt audioAccessToken String? @unique
status VoiceExperienceStatus @default(UPLOADED)
transcript String?
analysis Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }

View File

@@ -1,8 +1,6 @@
import 'dotenv/config'; import 'dotenv/config';
import { config } from '../config.js'; import { config } from '../config.js';
import { hatchet } from './hatchet-client.js';
import { processVoiceExperienceWorkflow } from './workflows/process-voice-experience.js';
function resolveWorkerSlots(): number { function resolveWorkerSlots(): number {
if (!Number.isFinite(config.workerSlots) || config.workerSlots <= 0) return 4; if (!Number.isFinite(config.workerSlots) || config.workerSlots <= 0) return 4;
@@ -14,6 +12,10 @@ async function main() {
throw new Error('HATCHET_CLIENT_TOKEN is required for hatchet worker'); throw new Error('HATCHET_CLIENT_TOKEN is required for hatchet worker');
} }
const { hatchet } = await import('./hatchet-client.js');
const { processVoiceExperienceWorkflow } = await import(
'./workflows/process-voice-experience.js'
);
const worker = await hatchet.worker(config.workerName, { const worker = await hatchet.worker(config.workerName, {
workflows: [processVoiceExperienceWorkflow], workflows: [processVoiceExperienceWorkflow],
slots: resolveWorkerSlots(), slots: resolveWorkerSlots(),

View File

@@ -1,6 +1,31 @@
import { buildPlaceAnalysis } from '../ontology/place-ontology.js'; import { config } from '../config.js';
import { placeOntology } from '../ontology/place-ontology.js';
import { prisma } from '../prisma.js'; import { prisma } from '../prisma.js';
type OpenRouterResponse = {
choices?: Array<{
finish_reason?: string;
message?: {
content?: string;
};
}>;
error?: {
message?: string;
};
};
type PlaceAnalysis = {
placeName: string;
tags: string[];
signals: Array<{
axis: string;
leaf: string;
evidence: string;
confidence: number;
}>;
summary: string;
};
export async function analyzeVoiceExperience(experienceId: string) { export async function analyzeVoiceExperience(experienceId: string) {
const experience = await prisma.voiceExperience.findUnique({ const experience = await prisma.voiceExperience.findUnique({
where: { id: experienceId }, where: { id: experienceId },
@@ -20,7 +45,7 @@ export async function analyzeVoiceExperience(experienceId: string) {
data: { status: 'ANALYZING' }, data: { status: 'ANALYZING' },
}); });
const analysis = buildPlaceAnalysis({ const analysis = await buildPlaceAnalysis({
placeName: experience.place.name, placeName: experience.place.name,
transcript: experience.transcript, transcript: experience.transcript,
}); });
@@ -35,3 +60,136 @@ export async function analyzeVoiceExperience(experienceId: string) {
return { tags: analysis.tags }; return { tags: analysis.tags };
} }
async function buildPlaceAnalysis(input: {
placeName: string;
transcript: string;
}): Promise<PlaceAnalysis> {
if (!config.openRouterApiKey) {
throw new Error('OPENROUTER_API_KEY is required for voice analysis.');
}
const ontology = placeOntology.map((axis) => ({
axis: axis.id,
options: axis.leaves.map((leaf) => ({
tag: `${axis.id}:${leaf.id}`,
keywords: leaf.keywords,
})),
}));
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
authorization: `Bearer ${config.openRouterApiKey}`,
'content-type': 'application/json',
'http-referer': 'https://map.craftee.vn',
'x-title': 'MapFlow',
},
body: JSON.stringify({
model: config.openRouterModel,
provider: { require_parameters: true },
temperature: 0.1,
max_tokens: 2000,
response_format: {
type: 'json_schema',
json_schema: {
name: 'place_analysis',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
required: ['placeName', 'tags', 'signals', 'summary'],
properties: {
placeName: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } },
signals: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['axis', 'leaf', 'evidence', 'confidence'],
properties: {
axis: { type: 'string' },
leaf: { type: 'string' },
evidence: { type: 'string' },
confidence: { type: 'number', minimum: 0, maximum: 1 },
},
},
},
summary: { type: 'string' },
},
},
},
},
messages: [
{
role: 'system',
content:
'You classify voice reviews about places. Return one valid JSON object in message.content. Do not return markdown. Use only allowed ontology tags.',
},
{
role: 'user',
content: JSON.stringify({
task:
'Analyze the transcript and select place-experience tags that are explicitly supported by the text. Do not invent facts.',
outputShape: {
placeName: 'string',
tags: ['axis:leaf'],
signals: [
{
axis: 'string',
leaf: 'string',
evidence: 'short quote or paraphrase from transcript',
confidence: 'number from 0 to 1',
},
],
summary: 'one short sentence',
},
placeName: input.placeName,
ontology,
transcript: input.transcript,
}),
},
],
}),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`OpenRouter analysis failed with ${response.status}: ${errorBody.slice(0, 500)}`,
);
}
const payload = (await response.json()) as OpenRouterResponse;
const choice = payload.choices?.[0];
const content = choice?.message?.content?.trim();
if (!content) {
throw new Error(
`OpenRouter returned an empty analysis for ${config.openRouterModel}; finish_reason=${choice?.finish_reason ?? 'missing'}.`,
);
}
const analysis = JSON.parse(content) as PlaceAnalysis;
assertValidAnalysis(analysis);
return analysis;
}
function assertValidAnalysis(analysis: PlaceAnalysis) {
const allowedTags = new Set(
placeOntology.flatMap((axis) =>
axis.leaves.map((leaf) => `${axis.id}:${leaf.id}`),
),
);
if (!Array.isArray(analysis.tags)) {
throw new Error('OpenRouter analysis tags must be an array.');
}
for (const tag of analysis.tags) {
if (!allowedTags.has(tag)) {
throw new Error(`OpenRouter returned unsupported tag: ${tag}.`);
}
}
if (!Array.isArray(analysis.signals)) {
throw new Error('OpenRouter analysis signals must be an array.');
}
}

View File

@@ -1,5 +1,19 @@
import { config } from '../config.js';
import { prisma } from '../prisma.js'; import { prisma } from '../prisma.js';
type DeepgramResponse = {
results?: {
channels?: Array<{
alternatives?: Array<{
transcript?: string;
paragraphs?: {
transcript?: string;
};
}>;
}>;
};
};
export async function transcribeVoiceExperience(experienceId: string) { export async function transcribeVoiceExperience(experienceId: string) {
const experience = await prisma.voiceExperience.findUnique({ const experience = await prisma.voiceExperience.findUnique({
where: { id: experienceId }, where: { id: experienceId },
@@ -14,11 +28,13 @@ export async function transcribeVoiceExperience(experienceId: string) {
data: { status: 'TRANSCRIBING' }, data: { status: 'TRANSCRIBING' },
}); });
const transcript = await transcribeAudioObject(experience.audioObjectKey); const transcript = await transcribeAudioObject(experience.id);
await prisma.voiceExperience.update({ await prisma.voiceExperience.update({
where: { id: experienceId }, where: { id: experienceId },
data: { data: {
audioContentBase64: null,
audioAccessToken: null,
transcript, transcript,
status: 'TRANSCRIBED', status: 'TRANSCRIBED',
}, },
@@ -27,7 +43,68 @@ export async function transcribeVoiceExperience(experienceId: string) {
return { transcript }; return { transcript };
} }
async function transcribeAudioObject(audioObjectKey: string): Promise<string> { async function transcribeAudioObject(experienceId: string): Promise<string> {
// TODO: replace this adapter with the production speech-to-text provider. if (!config.deepgramApiKey) {
return `Transcription placeholder for ${audioObjectKey}`; throw new Error('DEEPGRAM_API_KEY is required for transcription.');
}
const experience = await prisma.voiceExperience.findUnique({
where: { id: experienceId },
select: {
id: true,
audioObjectKey: true,
audioAccessToken: true,
audioContentBase64: true,
audioMimeType: true,
},
});
if (!experience) {
throw new Error(`Voice experience ${experienceId} was not found.`);
}
if (!experience.audioAccessToken) {
throw new Error(
`Voice audio ${experience.audioObjectKey} has no access token.`,
);
}
if (!experience.audioContentBase64 || !experience.audioMimeType) {
throw new Error(
`Voice audio ${experience.audioObjectKey} has no stored content.`,
);
}
const params = new URLSearchParams({
model: config.deepgramModel,
language: config.deepgramLanguage,
smart_format: 'true',
punctuate: 'true',
});
const audioUrl = `${config.publicApiUrl.replace(/\/$/, '')}/audio/voice-experiences/${experience.id}?token=${encodeURIComponent(experience.audioAccessToken)}`;
const response = await fetch(
`https://api.deepgram.com/v1/listen?${params.toString()}`,
{
method: 'POST',
headers: {
authorization: `Token ${config.deepgramApiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify({ url: audioUrl }),
},
);
if (!response.ok) {
throw new Error(`Deepgram transcription failed with ${response.status}.`);
}
const payload = (await response.json()) as DeepgramResponse;
const alternative = payload.results?.channels?.[0]?.alternatives?.[0];
const transcript =
alternative?.paragraphs?.transcript?.trim() ??
alternative?.transcript?.trim() ??
'';
if (!transcript) {
throw new Error('Deepgram returned an empty transcript.');
}
return transcript;
} }

View File

@@ -31,7 +31,7 @@ export const placeOntology: OntologyAxis[] = [
], ],
}, },
{ {
id: 'intent', id: 'function',
leaves: [ leaves: [
{ id: 'reset', keywords: ['rest', 'breathe', 'reset', 'recover'] }, { id: 'reset', keywords: ['rest', 'breathe', 'reset', 'recover'] },
{ id: 'impress', keywords: ['impress', 'beautiful', 'special', 'wow'] }, { id: 'impress', keywords: ['impress', 'beautiful', 'special', 'wow'] },

65
src/vault/env.ts Normal file
View File

@@ -0,0 +1,65 @@
type VaultConfig = {
address: string;
token: string;
mount: string;
sharedPath: string;
projectPath: string;
};
function requireEnv(name: string) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required when VAULT_ENABLED=true.`);
}
return value;
}
function vaultConfig(): VaultConfig {
return {
address: requireEnv('VAULT_ADDR').replace(/\/$/, ''),
token: requireEnv('VAULT_TOKEN'),
mount: requireEnv('VAULT_KV_MOUNT'),
sharedPath: requireEnv('VAULT_SHARED_PATH'),
projectPath: requireEnv('VAULT_PROJECT_PATH'),
};
}
async function readVaultPath(config: VaultConfig, path: string) {
const response = await fetch(
`${config.address}/v1/${config.mount}/data/${path}`,
{ headers: { 'X-Vault-Token': config.token } },
);
if (!response.ok) {
throw new Error(`Vault read failed for ${path}: ${response.status}.`);
}
const payload = (await response.json()) as {
data?: { data?: Record<string, unknown> };
};
const data = payload.data?.data;
if (!data) {
throw new Error(`Vault path ${path} has no KV v2 data.`);
}
return data;
}
function applyEnvironment(values: Record<string, unknown>) {
for (const [key, value] of Object.entries(values)) {
if (typeof value !== 'string') {
throw new Error(`Vault value ${key} must be a string.`);
}
process.env[key] = value;
}
}
export async function loadVaultEnvironment() {
if (process.env.VAULT_ENABLED !== 'true') {
return;
}
const config = vaultConfig();
applyEnvironment(await readVaultPath(config, config.sharedPath));
applyEnvironment(await readVaultPath(config, config.projectPath));
}