Compare commits
23 Commits
21e9f8703c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07ffd95a02 | ||
|
|
842d151a44 | ||
|
|
9642bffd16 | ||
|
|
f59a730169 | ||
|
|
53a7cb7cc8 | ||
|
|
f6caaac28f | ||
|
|
a8831e5581 | ||
|
|
d18802304a | ||
|
|
4f6cde166e | ||
|
|
ddb276b3f1 | ||
|
|
12f9562dbc | ||
|
|
f781845ec7 | ||
|
|
2816252e6c | ||
|
|
5b9b2a71d7 | ||
|
|
b231eb6a27 | ||
|
|
a6203bde71 | ||
|
|
2d305aec98 | ||
|
|
dc26274cf8 | ||
|
|
97b00e08fb | ||
|
|
0997ef7f91 | ||
|
|
a4f9b5b526 | ||
|
|
eeec5e3f8a | ||
|
|
ada03ac070 |
60
.gitea/workflows/build-and-deploy.yml
Normal file
60
.gitea/workflows/build-and-deploy.yml
Normal 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" ]
|
||||
@@ -5,10 +5,9 @@ RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
ENV DATABASE_URL="postgresql://mapflow:mapflow@localhost:5432/mapflow"
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run prisma:generate
|
||||
RUN DATABASE_URL="postgresql://mapflow:mapflow@localhost:5432/mapflow" npm run prisma:generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
@@ -17,7 +16,7 @@ ENV NODE_ENV=production
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
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.config.ts ./prisma.config.ts
|
||||
CMD ["node", "dist/hatchet/worker.js"]
|
||||
|
||||
@@ -17,25 +17,46 @@ enum VoiceExperienceStatus {
|
||||
}
|
||||
|
||||
model Place {
|
||||
id String @id @default(cuid())
|
||||
googlePlaceId String @unique
|
||||
name String
|
||||
latitude Float
|
||||
longitude Float
|
||||
experiences VoiceExperience[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
googlePlaceId String @unique
|
||||
name String
|
||||
latitude Float
|
||||
longitude Float
|
||||
googlePrimaryType String?
|
||||
googleTypes String[] @default([])
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
placeId String
|
||||
place Place @relation(fields: [placeId], references: [id])
|
||||
durationSeconds Int
|
||||
audioObjectKey String
|
||||
status VoiceExperienceStatus @default(UPLOADED)
|
||||
transcript String?
|
||||
analysis Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
placeId String
|
||||
place Place @relation(fields: [placeId], references: [id])
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
durationSeconds Int
|
||||
audioObjectKey String
|
||||
audioContentBase64 String?
|
||||
audioMimeType String?
|
||||
audioAccessToken String? @unique
|
||||
status VoiceExperienceStatus @default(UPLOADED)
|
||||
transcript String?
|
||||
analysis Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { loadVaultEnvironment } from './vault/env.js';
|
||||
|
||||
await loadVaultEnvironment();
|
||||
|
||||
export const config = {
|
||||
databaseUrl: process.env.DATABASE_URL ?? '',
|
||||
workerName: process.env.HATCHET_WORKER_NAME ?? 'mapflow-hatchet-worker',
|
||||
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
@@ -126,6 +126,21 @@ exports.Prisma.PlaceScalarFieldEnum = {
|
||||
name: 'name',
|
||||
latitude: 'latitude',
|
||||
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',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
@@ -133,8 +148,12 @@ exports.Prisma.PlaceScalarFieldEnum = {
|
||||
exports.Prisma.VoiceExperienceScalarFieldEnum = {
|
||||
id: 'id',
|
||||
placeId: 'placeId',
|
||||
userId: 'userId',
|
||||
durationSeconds: 'durationSeconds',
|
||||
audioObjectKey: 'audioObjectKey',
|
||||
audioContentBase64: 'audioContentBase64',
|
||||
audioMimeType: 'audioMimeType',
|
||||
audioAccessToken: 'audioAccessToken',
|
||||
status: 'status',
|
||||
transcript: 'transcript',
|
||||
analysis: 'analysis',
|
||||
@@ -157,16 +176,16 @@ exports.Prisma.QueryMode = {
|
||||
insensitive: 'insensitive'
|
||||
};
|
||||
|
||||
exports.Prisma.NullsOrder = {
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
};
|
||||
|
||||
exports.Prisma.JsonNullValueFilter = {
|
||||
DbNull: Prisma.DbNull,
|
||||
JsonNull: Prisma.JsonNull,
|
||||
AnyNull: Prisma.AnyNull
|
||||
};
|
||||
|
||||
exports.Prisma.NullsOrder = {
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
};
|
||||
exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
|
||||
UPLOADED: 'UPLOADED',
|
||||
TRANSCRIBING: 'TRANSCRIBING',
|
||||
@@ -178,6 +197,7 @@ exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
|
||||
|
||||
exports.Prisma.ModelName = {
|
||||
Place: 'Place',
|
||||
User: 'User',
|
||||
VoiceExperience: 'VoiceExperience'
|
||||
};
|
||||
|
||||
|
||||
2252
src/generated/prisma/index.d.ts
vendored
2252
src/generated/prisma/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-5316d8e66293a4954be6e26169692366997bf25fe9186e98c87e62eed3dc691e",
|
||||
"name": "prisma-client-404088dc715856cb4ed2e7f2d4b8eb35c87427ce32c0e67bc04d6ff67961fc2b",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "default.js",
|
||||
|
||||
@@ -17,25 +17,46 @@ enum VoiceExperienceStatus {
|
||||
}
|
||||
|
||||
model Place {
|
||||
id String @id @default(cuid())
|
||||
googlePlaceId String @unique
|
||||
name String
|
||||
latitude Float
|
||||
longitude Float
|
||||
experiences VoiceExperience[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
googlePlaceId String @unique
|
||||
name String
|
||||
latitude Float
|
||||
longitude Float
|
||||
googlePrimaryType String?
|
||||
googleTypes String[] @default([])
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
placeId String
|
||||
place Place @relation(fields: [placeId], references: [id])
|
||||
durationSeconds Int
|
||||
audioObjectKey String
|
||||
status VoiceExperienceStatus @default(UPLOADED)
|
||||
transcript String?
|
||||
analysis Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
placeId String
|
||||
place Place @relation(fields: [placeId], references: [id])
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
durationSeconds Int
|
||||
audioObjectKey String
|
||||
audioContentBase64 String?
|
||||
audioMimeType String?
|
||||
audioAccessToken String? @unique
|
||||
status VoiceExperienceStatus @default(UPLOADED)
|
||||
transcript String?
|
||||
analysis Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { config } from '../config.js';
|
||||
import { hatchet } from './hatchet-client.js';
|
||||
import { processVoiceExperienceWorkflow } from './workflows/process-voice-experience.js';
|
||||
|
||||
function resolveWorkerSlots(): number {
|
||||
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');
|
||||
}
|
||||
|
||||
const { hatchet } = await import('./hatchet-client.js');
|
||||
const { processVoiceExperienceWorkflow } = await import(
|
||||
'./workflows/process-voice-experience.js'
|
||||
);
|
||||
const worker = await hatchet.worker(config.workerName, {
|
||||
workflows: [processVoiceExperienceWorkflow],
|
||||
slots: resolveWorkerSlots(),
|
||||
|
||||
@@ -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';
|
||||
|
||||
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) {
|
||||
const experience = await prisma.voiceExperience.findUnique({
|
||||
where: { id: experienceId },
|
||||
@@ -20,7 +45,7 @@ export async function analyzeVoiceExperience(experienceId: string) {
|
||||
data: { status: 'ANALYZING' },
|
||||
});
|
||||
|
||||
const analysis = buildPlaceAnalysis({
|
||||
const analysis = await buildPlaceAnalysis({
|
||||
placeName: experience.place.name,
|
||||
transcript: experience.transcript,
|
||||
});
|
||||
@@ -35,3 +60,136 @@ export async function analyzeVoiceExperience(experienceId: string) {
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { config } from '../config.js';
|
||||
import { prisma } from '../prisma.js';
|
||||
|
||||
type DeepgramResponse = {
|
||||
results?: {
|
||||
channels?: Array<{
|
||||
alternatives?: Array<{
|
||||
transcript?: string;
|
||||
paragraphs?: {
|
||||
transcript?: string;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export async function transcribeVoiceExperience(experienceId: string) {
|
||||
const experience = await prisma.voiceExperience.findUnique({
|
||||
where: { id: experienceId },
|
||||
@@ -14,11 +28,13 @@ export async function transcribeVoiceExperience(experienceId: string) {
|
||||
data: { status: 'TRANSCRIBING' },
|
||||
});
|
||||
|
||||
const transcript = await transcribeAudioObject(experience.audioObjectKey);
|
||||
const transcript = await transcribeAudioObject(experience.id);
|
||||
|
||||
await prisma.voiceExperience.update({
|
||||
where: { id: experienceId },
|
||||
data: {
|
||||
audioContentBase64: null,
|
||||
audioAccessToken: null,
|
||||
transcript,
|
||||
status: 'TRANSCRIBED',
|
||||
},
|
||||
@@ -27,7 +43,68 @@ export async function transcribeVoiceExperience(experienceId: string) {
|
||||
return { transcript };
|
||||
}
|
||||
|
||||
async function transcribeAudioObject(audioObjectKey: string): Promise<string> {
|
||||
// TODO: replace this adapter with the production speech-to-text provider.
|
||||
return `Transcription placeholder for ${audioObjectKey}`;
|
||||
async function transcribeAudioObject(experienceId: string): Promise<string> {
|
||||
if (!config.deepgramApiKey) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const placeOntology: OntologyAxis[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'intent',
|
||||
id: 'function',
|
||||
leaves: [
|
||||
{ id: 'reset', keywords: ['rest', 'breathe', 'reset', 'recover'] },
|
||||
{ id: 'impress', keywords: ['impress', 'beautiful', 'special', 'wow'] },
|
||||
|
||||
65
src/vault/env.ts
Normal file
65
src/vault/env.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user