Implement RPC contract validation baseline
This commit is contained in:
@@ -6,6 +6,10 @@
|
||||
"./rpc": {
|
||||
"types": "./src/rpc.ts",
|
||||
"import": "./src/rpc.ts"
|
||||
},
|
||||
"./rpcSchemas": {
|
||||
"types": "./src/rpcSchemas.ts",
|
||||
"import": "./src/rpcSchemas.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,15 @@ export const MemoSectionReviewSchema = z.object({
|
||||
});
|
||||
export type MemoSectionReview = z.infer<typeof MemoSectionReviewSchema>;
|
||||
|
||||
export const MemoSchema = z.object({
|
||||
status: z.enum(["draft", "review", "final"]),
|
||||
sections: z.array(MemoSectionSchema),
|
||||
citations: z.array(MemoCitationSchema),
|
||||
annotations: z.array(MemoAnnotationSchema),
|
||||
sectionReviews: z.array(MemoSectionReviewSchema)
|
||||
});
|
||||
export type Memo = z.infer<typeof MemoSchema>;
|
||||
|
||||
export const CatalystSchema = z.object({
|
||||
id: z.string(),
|
||||
date: z.string(),
|
||||
@@ -174,19 +183,9 @@ export const SnapshotSchema = z.object({
|
||||
});
|
||||
export type Snapshot = z.infer<typeof SnapshotSchema>;
|
||||
|
||||
export type ClientSettings = {
|
||||
theme: "light" | "dark" | "system";
|
||||
density: "comfortable" | "compact" | "dense";
|
||||
sidebarWidth: number;
|
||||
navCollapsed: Record<string, boolean>;
|
||||
keybindings: Record<string, string>;
|
||||
};
|
||||
export type ClientSettings = z.infer<typeof import("./rpcSchemas.js").ClientSettingsSchema>;
|
||||
|
||||
export type ServerSettings = {
|
||||
agentConfigs: Record<string, unknown>;
|
||||
dataSources: Record<string, boolean>;
|
||||
exportPipelines: Record<string, unknown>;
|
||||
};
|
||||
export type ServerSettings = z.infer<typeof import("./rpcSchemas.js").ServerSettingsSchema>;
|
||||
|
||||
export type RpcRequestMap = {
|
||||
"portfolio.get": undefined;
|
||||
@@ -239,11 +238,13 @@ export type RpcRequestMap = {
|
||||
"agent.configure": { agentId: string; config: Record<string, unknown> };
|
||||
"agent.getTrace": { agentId: string; runId: string };
|
||||
"agent.runPipeline": { companyId: string; pipeline: string };
|
||||
"validation.run": { companyId: string; agentType?: "sv" | "qa" | "rt" | "all" };
|
||||
"validation.getStatus": { companyId: string; sectionId?: string };
|
||||
"export.list": { companyId?: string };
|
||||
"export.create": { type: string; companyId: string; options?: Record<string, unknown> };
|
||||
"export.create": { type: "pdf" | "excel" | "ppt"; companyId: string; options?: Record<string, unknown> };
|
||||
"export.download": { exportId: string };
|
||||
"settings.get": { scope: "client" | "server" };
|
||||
"settings.update": { scope: "client" | "server"; changes: Record<string, unknown> };
|
||||
"settings.update": import("./rpcSchemas.js").SettingsUpdatePayload;
|
||||
};
|
||||
|
||||
export type RpcResponseMap = {
|
||||
@@ -289,6 +290,12 @@ export type RpcResponseMap = {
|
||||
"agent.configure": { ok: boolean };
|
||||
"agent.getTrace": { steps: Array<{ step: number; label: string; detail: string }> };
|
||||
"agent.runPipeline": { runIds: string[] };
|
||||
"validation.run": {
|
||||
sourceVerification?: { passed: boolean; confidence: string; issues: Array<{ severity: string; message: string; suggestion?: string }>; notes: string; timestamp: string };
|
||||
modelQA?: { passed: boolean; confidence: string; issues: Array<{ severity: string; message: string; suggestion?: string }>; notes: string; timestamp: string };
|
||||
redTeam?: { passed: boolean; confidence: string; issues: Array<{ severity: string; message: string; suggestion?: string }>; notes: string; timestamp: string };
|
||||
};
|
||||
"validation.getStatus": { validationState: "verified" | "flagged" | "unverified" | "failed"; lastValidated?: string };
|
||||
"export.list": { exports: ExportRecord[] };
|
||||
"export.create": { exportId: string };
|
||||
"export.download": { data: ArrayBuffer };
|
||||
@@ -311,3 +318,114 @@ export type RpcResult<T extends RpcMethod> =
|
||||
export type RpcClient = {
|
||||
call<T extends RpcMethod>(method: T, payload: RpcRequestMap[T]): Promise<RpcResult<T>>;
|
||||
};
|
||||
|
||||
// ============== SSE Events ==============
|
||||
|
||||
export type ServerEventType =
|
||||
| "agent.progress"
|
||||
| "agent.completed"
|
||||
| "agent.failed"
|
||||
| "agent.started"
|
||||
| "agent.streaming"
|
||||
| "validation.updated"
|
||||
| "memo.updated"
|
||||
| "model.updated";
|
||||
|
||||
export type AgentProgressEvent = {
|
||||
type: "agent.progress";
|
||||
data: {
|
||||
runId: string;
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
progress: number;
|
||||
action: string;
|
||||
timestamp: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentCompletedEvent = {
|
||||
type: "agent.completed";
|
||||
data: {
|
||||
runId: string;
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
output: unknown;
|
||||
duration: number;
|
||||
timestamp: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentFailedEvent = {
|
||||
type: "agent.failed";
|
||||
data: {
|
||||
runId: string;
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
error: string;
|
||||
timestamp: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentStartedEvent = {
|
||||
type: "agent.started";
|
||||
data: {
|
||||
runId: string;
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
pipeline?: string;
|
||||
timestamp: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentStreamingEvent = {
|
||||
type: "agent.streaming";
|
||||
data: {
|
||||
runId: string;
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
chunk: string;
|
||||
timestamp: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ValidationUpdatedEvent = {
|
||||
type: "validation.updated";
|
||||
data: {
|
||||
companyId: string;
|
||||
sectionId?: string;
|
||||
validationState: "verified" | "flagged" | "unverified" | "failed";
|
||||
agentId: string;
|
||||
notes?: string;
|
||||
timestamp: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MemoUpdatedEvent = {
|
||||
type: "memo.updated";
|
||||
data: {
|
||||
companyId: string;
|
||||
sectionId: string;
|
||||
content: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ModelUpdatedEvent = {
|
||||
type: "model.updated";
|
||||
data: {
|
||||
companyId: string;
|
||||
tab: string;
|
||||
cell?: { row: number; col: number };
|
||||
timestamp: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ServerEvent =
|
||||
| AgentProgressEvent
|
||||
| AgentCompletedEvent
|
||||
| AgentFailedEvent
|
||||
| AgentStartedEvent
|
||||
| AgentStreamingEvent
|
||||
| ValidationUpdatedEvent
|
||||
| MemoUpdatedEvent
|
||||
| ModelUpdatedEvent;
|
||||
|
||||
33
packages/contracts/src/rpcSchemas.test.ts
Normal file
33
packages/contracts/src/rpcSchemas.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RpcRequestSchemas, RpcResponseSchemas, parseRpcRequest } from "./rpcSchemas.js";
|
||||
|
||||
describe("RPC schemas", () => {
|
||||
it("defines request and response schemas for every method", () => {
|
||||
expect(Object.keys(RpcRequestSchemas).sort()).toEqual(Object.keys(RpcResponseSchemas).sort());
|
||||
});
|
||||
|
||||
it("accepts valid payloads and trims bounded strings", () => {
|
||||
expect(parseRpcRequest("portfolio.addHolding", { ticker: " cost " })).toEqual({ ticker: "cost" });
|
||||
expect(parseRpcRequest("model.updateCell", { companyId: "cost", tab: "base", row: 0, col: 1, value: "42" })).toEqual({
|
||||
companyId: "cost",
|
||||
tab: "base",
|
||||
row: 0,
|
||||
col: 1,
|
||||
value: "42",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid payloads", () => {
|
||||
expect(() => parseRpcRequest("portfolio.addHolding", { ticker: "" })).toThrow();
|
||||
expect(() => parseRpcRequest("model.updateCell", { companyId: "cost", tab: "base", row: -1, col: 0, value: "42" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects scope-mismatched settings updates", () => {
|
||||
expect(() => parseRpcRequest("settings.update", { scope: "client", changes: { sidebarWidth: 40 } })).toThrow();
|
||||
expect(() => parseRpcRequest("settings.update", { scope: "server", changes: { dataSources: { sec: "yes" } } })).toThrow();
|
||||
});
|
||||
|
||||
it("catches malformed handler output", () => {
|
||||
expect(RpcResponseSchemas["memo.updateSection"].safeParse({ section: {}, status: "draft", savedAt: "now" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
270
packages/contracts/src/rpcSchemas.ts
Normal file
270
packages/contracts/src/rpcSchemas.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AgentSchema,
|
||||
AlertSchema,
|
||||
CatalystSchema,
|
||||
CompanySchema,
|
||||
EarningsScheduleSchema,
|
||||
ExportRecordSchema,
|
||||
FilingSchema,
|
||||
HoldingSchema,
|
||||
MemoAnnotationSchema,
|
||||
MemoCitationSchema,
|
||||
MemoSectionReviewSchema,
|
||||
MemoSectionSchema,
|
||||
ModelRowSchema,
|
||||
RiskSchema,
|
||||
WorkspaceSectionSchema,
|
||||
type ClientSettings,
|
||||
type RpcMethod,
|
||||
type RpcRequestMap,
|
||||
type RpcResponseMap,
|
||||
type ServerSettings,
|
||||
} from "./rpc.js";
|
||||
|
||||
const nonEmptyString = z.string().trim().min(1);
|
||||
const idString = nonEmptyString;
|
||||
const tickerString = z.string().trim().min(1).max(16);
|
||||
const nonNegativeIndex = z.number().int().min(0);
|
||||
const unknownRecord = z.record(z.unknown());
|
||||
|
||||
export const ClientSettingsSchema = z.object({
|
||||
theme: z.enum(["light", "dark", "system"]),
|
||||
density: z.enum(["comfortable", "compact", "dense"]),
|
||||
sidebarWidth: z.number().int().min(160).max(520),
|
||||
navCollapsed: z.record(z.boolean()),
|
||||
keybindings: z.record(z.string()),
|
||||
});
|
||||
|
||||
export const ServerSettingsSchema = z.object({
|
||||
agentConfigs: z.record(z.unknown()),
|
||||
dataSources: z.record(z.boolean()),
|
||||
exportPipelines: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
const RiskInputSchema = RiskSchema.omit({ id: true, companyId: true });
|
||||
const SettingsUpdateSchema = z.discriminatedUnion("scope", [
|
||||
z.object({ scope: z.literal("client"), changes: ClientSettingsSchema.partial() }),
|
||||
z.object({ scope: z.literal("server"), changes: ServerSettingsSchema.partial() }),
|
||||
]);
|
||||
|
||||
export const RpcRequestSchemas = {
|
||||
"portfolio.get": z.undefined(),
|
||||
"portfolio.addHolding": z.object({ ticker: tickerString }),
|
||||
"portfolio.removeHolding": z.object({ ticker: tickerString }),
|
||||
"company.get": z.object({ companyId: idString }),
|
||||
"company.search": z.object({ query: nonEmptyString }),
|
||||
"company.setActive": z.object({ companyId: idString }),
|
||||
"workspace.getSection": z.object({ companyId: idString, section: nonEmptyString }),
|
||||
"workspace.listSources": z.object({ companyId: idString }),
|
||||
"catalyst.list": z.object({ companyId: idString }),
|
||||
"alert.list": z.object({ companyId: idString.optional(), since: z.string().optional() }),
|
||||
"risk.list": z.object({ companyId: idString }),
|
||||
"risk.add": z.object({ companyId: idString, risk: RiskInputSchema }),
|
||||
"earnings.getSchedule": z.object({ companyId: idString }),
|
||||
"filing.list": z.object({ companyId: idString, since: z.string().optional() }),
|
||||
"model.get": z.object({ companyId: idString, tab: nonEmptyString }),
|
||||
"model.updateCell": z.object({
|
||||
companyId: idString,
|
||||
tab: nonEmptyString,
|
||||
row: nonNegativeIndex,
|
||||
col: nonNegativeIndex,
|
||||
value: z.string(),
|
||||
}),
|
||||
"model.runScenario": z.object({
|
||||
companyId: idString,
|
||||
scenario: nonEmptyString,
|
||||
overrides: z.record(z.string()),
|
||||
}),
|
||||
"memo.get": z.object({ companyId: idString }),
|
||||
"memo.updateSection": z.object({
|
||||
companyId: idString,
|
||||
sectionId: idString,
|
||||
title: nonEmptyString.optional(),
|
||||
content: nonEmptyString,
|
||||
}),
|
||||
"memo.addAnnotation": z.object({
|
||||
companyId: idString,
|
||||
sectionId: idString,
|
||||
kind: z.enum(["highlight", "comment", "strike"]),
|
||||
selectedText: nonEmptyString,
|
||||
comment: nonEmptyString.optional(),
|
||||
}),
|
||||
"memo.resolveAnnotation": z.object({ companyId: idString, annotationId: idString }),
|
||||
"memo.updateSectionReview": z.object({
|
||||
companyId: idString,
|
||||
sectionId: idString,
|
||||
status: z.enum(["pending", "in_review", "approved", "changes_requested"]),
|
||||
}),
|
||||
"memo.acceptEdit": z.object({ companyId: idString, editId: idString }),
|
||||
"memo.rejectEdit": z.object({ companyId: idString, editId: idString, reason: z.string().optional() }),
|
||||
"agent.list": z.object({ companyId: idString.optional() }),
|
||||
"agent.start": z.object({ agentId: idString, companyId: idString }),
|
||||
"agent.pause": z.object({ agentId: idString }),
|
||||
"agent.restart": z.object({ agentId: idString }),
|
||||
"agent.chat": z.object({ agentId: idString, message: nonEmptyString }),
|
||||
"agent.configure": z.object({ agentId: idString, config: unknownRecord }),
|
||||
"agent.getTrace": z.object({ agentId: idString, runId: idString }),
|
||||
"agent.runPipeline": z.object({
|
||||
companyId: idString,
|
||||
pipeline: z.enum(["research", "competitive", "cross-cutting"]),
|
||||
}),
|
||||
"validation.run": z.object({ companyId: idString, agentType: z.enum(["sv", "qa", "rt", "all"]).optional() }),
|
||||
"validation.getStatus": z.object({ companyId: idString, sectionId: idString.optional() }),
|
||||
"export.list": z.object({ companyId: idString.optional() }),
|
||||
"export.create": z.object({ type: z.enum(["pdf", "excel", "ppt"]), companyId: idString, options: unknownRecord.optional() }),
|
||||
"export.download": z.object({ exportId: idString }),
|
||||
"settings.get": z.object({ scope: z.enum(["client", "server"]) }),
|
||||
"settings.update": SettingsUpdateSchema,
|
||||
} satisfies Record<RpcMethod, z.ZodTypeAny>;
|
||||
|
||||
const SettingsResponseSchema = z.union([ClientSettingsSchema, ServerSettingsSchema]);
|
||||
const ValidationIssueSchema = z.object({
|
||||
severity: z.string(),
|
||||
message: z.string(),
|
||||
suggestion: z.string().optional(),
|
||||
});
|
||||
const ValidationResultSchema = z.object({
|
||||
passed: z.boolean(),
|
||||
confidence: z.string(),
|
||||
issues: z.array(ValidationIssueSchema),
|
||||
notes: z.string(),
|
||||
timestamp: z.string(),
|
||||
});
|
||||
|
||||
export const RpcResponseSchemas = {
|
||||
"portfolio.get": z.object({ id: z.string(), name: z.string(), holdings: z.array(HoldingSchema), activeCompanyId: z.string() }),
|
||||
"portfolio.addHolding": z.object({ holding: HoldingSchema }),
|
||||
"portfolio.removeHolding": z.object({ ok: z.boolean() }),
|
||||
"company.get": z.object({ company: CompanySchema }),
|
||||
"company.search": z.object({ results: z.array(z.object({ ticker: z.string(), name: z.string(), sector: z.string() })) }),
|
||||
"company.setActive": z.object({ ok: z.boolean() }),
|
||||
"workspace.getSection": z.object({ content: WorkspaceSectionSchema, validationState: z.string() }),
|
||||
"workspace.listSources": z.object({ sources: z.array(z.object({ type: z.string(), title: z.string(), metadata: z.string() })) }),
|
||||
"catalyst.list": z.object({ catalysts: z.array(CatalystSchema) }),
|
||||
"alert.list": z.object({ alerts: z.array(AlertSchema) }),
|
||||
"risk.list": z.object({ risks: z.array(RiskSchema) }),
|
||||
"risk.add": z.object({ risk: RiskSchema }),
|
||||
"earnings.getSchedule": z.object({ schedule: z.array(EarningsScheduleSchema) }),
|
||||
"filing.list": z.object({ filings: z.array(FilingSchema) }),
|
||||
"model.get": z.object({ headers: z.array(z.string()), rows: z.array(ModelRowSchema) }),
|
||||
"model.updateCell": z.object({ ok: z.boolean(), affectedCells: z.array(z.string()) }),
|
||||
"model.runScenario": z.object({ headers: z.array(z.string()), rows: z.array(ModelRowSchema) }),
|
||||
"memo.get": z.object({
|
||||
status: z.enum(["draft", "review", "final"]),
|
||||
sections: z.array(MemoSectionSchema),
|
||||
citations: z.array(MemoCitationSchema),
|
||||
annotations: z.array(MemoAnnotationSchema),
|
||||
sectionReviews: z.array(MemoSectionReviewSchema),
|
||||
}),
|
||||
"memo.updateSection": z.object({
|
||||
section: MemoSectionSchema,
|
||||
status: z.enum(["draft", "review", "final"]),
|
||||
savedAt: z.string(),
|
||||
}),
|
||||
"memo.addAnnotation": z.object({ annotation: MemoAnnotationSchema }),
|
||||
"memo.resolveAnnotation": z.object({ annotation: MemoAnnotationSchema }),
|
||||
"memo.updateSectionReview": z.object({ review: MemoSectionReviewSchema }),
|
||||
"memo.acceptEdit": z.object({ ok: z.boolean() }),
|
||||
"memo.rejectEdit": z.object({ ok: z.boolean() }),
|
||||
"agent.list": z.object({ agents: z.array(AgentSchema) }),
|
||||
"agent.start": z.object({ runId: z.string() }),
|
||||
"agent.pause": z.object({ ok: z.boolean() }),
|
||||
"agent.restart": z.object({ runId: z.string() }),
|
||||
"agent.chat": z.object({ response: z.string() }),
|
||||
"agent.configure": z.object({ ok: z.boolean() }),
|
||||
"agent.getTrace": z.object({ steps: z.array(z.object({ step: z.number(), label: z.string(), detail: z.string() })) }),
|
||||
"agent.runPipeline": z.object({ runIds: z.array(z.string()) }),
|
||||
"validation.run": z.object({
|
||||
sourceVerification: ValidationResultSchema.optional(),
|
||||
modelQA: ValidationResultSchema.optional(),
|
||||
redTeam: ValidationResultSchema.optional(),
|
||||
}),
|
||||
"validation.getStatus": z.object({
|
||||
validationState: z.enum(["verified", "flagged", "unverified", "failed"]),
|
||||
lastValidated: z.string().optional(),
|
||||
}),
|
||||
"export.list": z.object({ exports: z.array(ExportRecordSchema) }),
|
||||
"export.create": z.object({ exportId: z.string() }),
|
||||
"export.download": z.object({ data: z.instanceof(ArrayBuffer) }),
|
||||
"settings.get": z.object({ settings: SettingsResponseSchema }),
|
||||
"settings.update": z.object({ ok: z.boolean() }),
|
||||
} satisfies Record<RpcMethod, z.ZodTypeAny>;
|
||||
|
||||
export const AgentProgressEventSchema = z.object({
|
||||
type: z.literal("agent.progress"),
|
||||
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), progress: z.number(), action: z.string(), timestamp: z.string() }),
|
||||
});
|
||||
export const AgentCompletedEventSchema = z.object({
|
||||
type: z.literal("agent.completed"),
|
||||
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), output: z.unknown(), duration: z.number(), timestamp: z.string() }),
|
||||
});
|
||||
export const AgentFailedEventSchema = z.object({
|
||||
type: z.literal("agent.failed"),
|
||||
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), error: z.string(), timestamp: z.string() }),
|
||||
});
|
||||
export const AgentStartedEventSchema = z.object({
|
||||
type: z.literal("agent.started"),
|
||||
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), pipeline: z.string().optional(), timestamp: z.string() }),
|
||||
});
|
||||
export const AgentStreamingEventSchema = z.object({
|
||||
type: z.literal("agent.streaming"),
|
||||
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), chunk: z.string(), timestamp: z.string() }),
|
||||
});
|
||||
export const ValidationUpdatedEventSchema = z.object({
|
||||
type: z.literal("validation.updated"),
|
||||
data: z.object({
|
||||
companyId: z.string(),
|
||||
sectionId: z.string().optional(),
|
||||
validationState: z.enum(["verified", "flagged", "unverified", "failed"]),
|
||||
agentId: z.string(),
|
||||
notes: z.string().optional(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
});
|
||||
export const MemoUpdatedEventSchema = z.object({
|
||||
type: z.literal("memo.updated"),
|
||||
data: z.object({ companyId: z.string(), sectionId: z.string(), content: z.string(), updatedAt: z.string() }),
|
||||
});
|
||||
export const ModelUpdatedEventSchema = z.object({
|
||||
type: z.literal("model.updated"),
|
||||
data: z.object({ companyId: z.string(), tab: z.string(), cell: z.object({ row: z.number(), col: z.number() }).optional(), timestamp: z.string() }),
|
||||
});
|
||||
|
||||
export const ServerEventSchema = z.discriminatedUnion("type", [
|
||||
AgentProgressEventSchema,
|
||||
AgentCompletedEventSchema,
|
||||
AgentFailedEventSchema,
|
||||
AgentStartedEventSchema,
|
||||
AgentStreamingEventSchema,
|
||||
ValidationUpdatedEventSchema,
|
||||
MemoUpdatedEventSchema,
|
||||
ModelUpdatedEventSchema,
|
||||
]);
|
||||
|
||||
export const ServerEventTypeSchema = z.enum([
|
||||
"agent.progress",
|
||||
"agent.completed",
|
||||
"agent.failed",
|
||||
"agent.started",
|
||||
"agent.streaming",
|
||||
"validation.updated",
|
||||
"memo.updated",
|
||||
"model.updated",
|
||||
]);
|
||||
|
||||
export function isRpcMethod(method: unknown): method is RpcMethod {
|
||||
return typeof method === "string" && method in RpcRequestSchemas;
|
||||
}
|
||||
|
||||
export function parseRpcRequest<T extends RpcMethod>(method: T, payload: unknown): RpcRequestMap[T] {
|
||||
return RpcRequestSchemas[method].parse(payload) as RpcRequestMap[T];
|
||||
}
|
||||
|
||||
export function parseRpcResponse<T extends RpcMethod>(method: T, data: unknown): RpcResponseMap[T] {
|
||||
return RpcResponseSchemas[method].parse(data) as RpcResponseMap[T];
|
||||
}
|
||||
|
||||
export type SettingsUpdatePayload =
|
||||
| { scope: "client"; changes: Partial<ClientSettings> }
|
||||
| { scope: "server"; changes: Partial<ServerSettings> };
|
||||
@@ -14,6 +14,35 @@
|
||||
"./mockRpc": {
|
||||
"types": "./src/mockRpc.ts",
|
||||
"import": "./src/mockRpc.ts"
|
||||
},
|
||||
"./db": {
|
||||
"types": "./src/db/index.ts",
|
||||
"import": "./src/db/index.ts"
|
||||
},
|
||||
"./data": {
|
||||
"types": "./src/data/index.ts",
|
||||
"import": "./src/data/index.ts"
|
||||
},
|
||||
"./llm": {
|
||||
"types": "./src/llm/index.ts",
|
||||
"import": "./src/llm/index.ts"
|
||||
},
|
||||
"./agents": {
|
||||
"types": "./src/agents/index.ts",
|
||||
"import": "./src/agents/index.ts"
|
||||
},
|
||||
"./export": {
|
||||
"types": "./src/export/index.ts",
|
||||
"import": "./src/export/index.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaiciq/contracts": "workspace:*",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"pptxgenjs": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13"
|
||||
}
|
||||
}
|
||||
|
||||
200
packages/shared/src/agents/eventEmitter.ts
Normal file
200
packages/shared/src/agents/eventEmitter.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Event emitter for server-sent events
|
||||
* Emits real-time events to the renderer process via IPC
|
||||
*/
|
||||
|
||||
type EventCallback = (event: unknown) => void;
|
||||
|
||||
class EventEmitter {
|
||||
private listeners: Map<string, Set<EventCallback>> = new Map();
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*/
|
||||
on(eventType: string, callback: EventCallback): () => void {
|
||||
if (!this.listeners.has(eventType)) {
|
||||
this.listeners.set(eventType, new Set());
|
||||
}
|
||||
this.listeners.get(eventType)!.add(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const callbacks = this.listeners.get(eventType);
|
||||
if (callbacks) {
|
||||
callbacks.delete(callback);
|
||||
if (callbacks.size === 0) {
|
||||
this.listeners.delete(eventType);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all subscribers
|
||||
*/
|
||||
emit(eventType: string, data: unknown): void {
|
||||
const callbacks = this.listeners.get(eventType);
|
||||
if (callbacks) {
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error(`[EventEmitter] Error in callback for ${eventType}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all listeners for an event type
|
||||
*/
|
||||
removeAllListeners(eventType?: string): void {
|
||||
if (eventType) {
|
||||
this.listeners.delete(eventType);
|
||||
} else {
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of listeners for an event type
|
||||
*/
|
||||
listenerCount(eventType: string): number {
|
||||
return this.listeners.get(eventType)?.size ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
export const eventEmitter = new EventEmitter();
|
||||
|
||||
/**
|
||||
* Helper function to emit agent progress events
|
||||
*/
|
||||
export function emitAgentProgress(
|
||||
runId: string,
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
progress: number,
|
||||
action: string
|
||||
): void {
|
||||
eventEmitter.emit("agent.progress", {
|
||||
type: "agent.progress",
|
||||
data: {
|
||||
runId,
|
||||
agentId,
|
||||
companyId,
|
||||
progress,
|
||||
action,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to emit agent started events
|
||||
*/
|
||||
export function emitAgentStarted(
|
||||
runId: string,
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
pipeline?: string
|
||||
): void {
|
||||
eventEmitter.emit("agent.started", {
|
||||
type: "agent.started",
|
||||
data: {
|
||||
runId,
|
||||
agentId,
|
||||
companyId,
|
||||
pipeline,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to emit agent completed events
|
||||
*/
|
||||
export function emitAgentCompleted(
|
||||
runId: string,
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
output: unknown,
|
||||
duration: number
|
||||
): void {
|
||||
eventEmitter.emit("agent.completed", {
|
||||
type: "agent.completed",
|
||||
data: {
|
||||
runId,
|
||||
agentId,
|
||||
companyId,
|
||||
output,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to emit agent failed events
|
||||
*/
|
||||
export function emitAgentFailed(
|
||||
runId: string,
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
error: string
|
||||
): void {
|
||||
eventEmitter.emit("agent.failed", {
|
||||
type: "agent.failed",
|
||||
data: {
|
||||
runId,
|
||||
agentId,
|
||||
companyId,
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to emit agent streaming events (for live output)
|
||||
*/
|
||||
export function emitAgentStreaming(
|
||||
runId: string,
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
chunk: string
|
||||
): void {
|
||||
eventEmitter.emit("agent.streaming", {
|
||||
type: "agent.streaming",
|
||||
data: {
|
||||
runId,
|
||||
agentId,
|
||||
companyId,
|
||||
chunk,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to emit validation updated events
|
||||
*/
|
||||
export function emitValidationUpdated(
|
||||
companyId: string,
|
||||
sectionId: string | undefined,
|
||||
validationState: "verified" | "flagged" | "unverified" | "failed",
|
||||
agentId: string,
|
||||
notes?: string
|
||||
): void {
|
||||
eventEmitter.emit("validation.updated", {
|
||||
type: "validation.updated",
|
||||
data: {
|
||||
companyId,
|
||||
sectionId,
|
||||
validationState,
|
||||
agentId,
|
||||
notes,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
336
packages/shared/src/agents/executor.ts
Normal file
336
packages/shared/src/agents/executor.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Agent pipeline executor
|
||||
* Orchestrates multi-agent workflows with dependency management
|
||||
*/
|
||||
|
||||
import type { Db } from "../db/database.js";
|
||||
import { executeAgent, type AgentRunResult } from "./runner.js";
|
||||
import { getAgentsByPipeline } from "../llm/prompts.js";
|
||||
|
||||
export interface PipelineExecutionOptions {
|
||||
onProgress?: (agentId: string, progress: number, action: string) => void;
|
||||
onAgentComplete?: (result: AgentRunResult) => void;
|
||||
signal?: AbortSignal;
|
||||
continueOnError?: boolean;
|
||||
}
|
||||
|
||||
export interface DependencyGraph {
|
||||
nodes: string[];
|
||||
edges: Array<[string, string]>;
|
||||
}
|
||||
|
||||
// Pipeline definitions
|
||||
const PIPELINE_GRAPHS: Record<string, DependencyGraph> = {
|
||||
research: {
|
||||
nodes: ["sf", "cr", "fm", "va", "mw", "pa"],
|
||||
edges: [
|
||||
["sf", "cr"],
|
||||
["cr", "fm"],
|
||||
["fm", "va"],
|
||||
["va", "mw"],
|
||||
["mw", "pa"],
|
||||
],
|
||||
},
|
||||
competitive: {
|
||||
nodes: ["ec", "ci", "rk", "rt"],
|
||||
edges: [
|
||||
["ec", "ci"],
|
||||
["ci", "rk"],
|
||||
["rk", "rt"],
|
||||
],
|
||||
},
|
||||
"cross-cutting": {
|
||||
nodes: ["mn", "sv", "ex", "qa"],
|
||||
edges: [
|
||||
["sv", "qa"],
|
||||
["qa", "ex"],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a pipeline of agents
|
||||
*/
|
||||
export async function executePipeline(
|
||||
db: Db,
|
||||
pipeline: string,
|
||||
companyId: string,
|
||||
options: PipelineExecutionOptions = {}
|
||||
): Promise<AgentRunResult[]> {
|
||||
const {
|
||||
onProgress,
|
||||
onAgentComplete,
|
||||
signal,
|
||||
continueOnError = false,
|
||||
} = options;
|
||||
|
||||
const graph = PIPELINE_GRAPHS[pipeline];
|
||||
if (!graph) {
|
||||
throw new Error(`Unknown pipeline: ${pipeline}`);
|
||||
}
|
||||
|
||||
const results: AgentRunResult[] = [];
|
||||
|
||||
// Get execution order (topological sort)
|
||||
const executionOrder = topologicalSort(graph);
|
||||
|
||||
// Execute agents in order
|
||||
for (const agentId of executionOrder) {
|
||||
// Check for cancellation
|
||||
if (signal?.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
onProgress?.(agentId, 0, `Starting ${agentId}`);
|
||||
|
||||
const result = await executeAgent(db, agentId, companyId, {
|
||||
onProgress: (progress, action) => {
|
||||
onProgress?.(agentId, progress, action);
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
results.push(result);
|
||||
onAgentComplete?.(result);
|
||||
|
||||
// Stop if agent failed and we're not continuing on error
|
||||
if (result.status === "failed" && !continueOnError) {
|
||||
console.error(`[Pipeline] Agent ${agentId} failed, stopping pipeline`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Validation checkpoint for certain agents
|
||||
if (["va", "rk", "qa"].includes(agentId)) {
|
||||
const shouldProceed = await waitForApproval(agentId, companyId);
|
||||
if (!shouldProceed) {
|
||||
console.log(`[Pipeline] User cancelled pipeline at ${agentId}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Pipeline] Error executing ${agentId}:`, errorMsg);
|
||||
|
||||
const failedResult: AgentRunResult = {
|
||||
runId: `run-${Date.now()}`,
|
||||
agentId,
|
||||
companyId,
|
||||
output: null,
|
||||
rawResponse: "",
|
||||
status: "failed",
|
||||
error: errorMsg,
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
results.push(failedResult);
|
||||
|
||||
if (!continueOnError) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Topological sort of dependency graph
|
||||
*/
|
||||
function topologicalSort(graph: DependencyGraph): string[] {
|
||||
const { nodes, edges } = graph;
|
||||
|
||||
// Build adjacency list and in-degree count
|
||||
const inDegree: Record<string, number> = {};
|
||||
const adjacency: Record<string, string[]> = {};
|
||||
|
||||
for (const node of nodes) {
|
||||
inDegree[node] = 0;
|
||||
adjacency[node] = [];
|
||||
}
|
||||
|
||||
for (const [from, to] of edges) {
|
||||
adjacency[from].push(to);
|
||||
inDegree[to]++;
|
||||
}
|
||||
|
||||
// Kahn's algorithm
|
||||
const sorted: string[] = [];
|
||||
const queue: string[] = [];
|
||||
|
||||
// Start with nodes that have no dependencies
|
||||
for (const node of nodes) {
|
||||
if (inDegree[node] === 0) {
|
||||
queue.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift()!;
|
||||
sorted.push(node);
|
||||
|
||||
for (const neighbor of adjacency[node]) {
|
||||
inDegree[neighbor]--;
|
||||
if (inDegree[neighbor] === 0) {
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for user approval at validation checkpoints
|
||||
* For now, this always returns true
|
||||
* In a production system, this would block and wait for user input
|
||||
*/
|
||||
async function waitForApproval(agentId: string, companyId: string): Promise<boolean> {
|
||||
// TODO: Implement approval mechanism
|
||||
// For now, auto-approve
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available pipelines
|
||||
*/
|
||||
export function getAvailablePipelines(): string[] {
|
||||
return Object.keys(PIPELINE_GRAPHS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pipeline info
|
||||
*/
|
||||
export function getPipelineInfo(pipeline: string): { name: string; description: string; agents: string[] } | null {
|
||||
const agents = getAgentsByPipeline(pipeline as any);
|
||||
if (agents.length === 0) return null;
|
||||
|
||||
const descriptions: Record<string, string> = {
|
||||
research: "Full research pipeline from SEC filings to presentation",
|
||||
competitive: "Competitive analysis and risk assessment",
|
||||
"cross-cutting": "Validation and export tasks",
|
||||
};
|
||||
|
||||
return {
|
||||
name: pipeline.charAt(0).toUpperCase() + pipeline.slice(1),
|
||||
description: descriptions[pipeline] || "",
|
||||
agents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather inputs for an agent from previous results
|
||||
*/
|
||||
export function gatherInputs(
|
||||
db: Db,
|
||||
agentId: string,
|
||||
previousResults: AgentRunResult[]
|
||||
): Record<string, unknown> {
|
||||
const inputs: Record<string, unknown> = {};
|
||||
|
||||
for (const result of previousResults) {
|
||||
if (result.status === "completed" && result.output) {
|
||||
inputs[result.agentId] = result.output;
|
||||
}
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all agents in a pipeline in parallel (where possible)
|
||||
*/
|
||||
export async function executePipelineParallel(
|
||||
db: Db,
|
||||
pipeline: string,
|
||||
companyId: string,
|
||||
options: PipelineExecutionOptions = {}
|
||||
): Promise<AgentRunResult[]> {
|
||||
const {
|
||||
onProgress,
|
||||
onAgentComplete,
|
||||
signal,
|
||||
} = options;
|
||||
|
||||
const graph = PIPELINE_GRAPHS[pipeline];
|
||||
if (!graph) {
|
||||
throw new Error(`Unknown pipeline: ${pipeline}`);
|
||||
}
|
||||
|
||||
const results: AgentRunResult[] = [];
|
||||
const completed = new Set<string>();
|
||||
|
||||
// Process agents in waves based on dependencies
|
||||
const executionOrder = topologicalSort(graph);
|
||||
|
||||
// Group agents by wave (agents that can run in parallel)
|
||||
const waves: string[][] = [];
|
||||
const currentWave: string[] = [];
|
||||
const inCurrentWave = new Set<string>(executionOrder);
|
||||
|
||||
for (const agentId of executionOrder) {
|
||||
// Check if this agent depends on any agent in the current wave
|
||||
const dependsOnCurrentWave = graph.edges.some(
|
||||
([from, to]) => to === agentId && currentWave.includes(from)
|
||||
);
|
||||
|
||||
if (dependsOnCurrentWave && currentWave.length > 0) {
|
||||
waves.push([...currentWave]);
|
||||
currentWave.length = 0;
|
||||
}
|
||||
|
||||
currentWave.push(agentId);
|
||||
}
|
||||
|
||||
if (currentWave.length > 0) {
|
||||
waves.push(currentWave);
|
||||
}
|
||||
|
||||
// Execute each wave
|
||||
for (const wave of waves) {
|
||||
if (signal?.aborted) break;
|
||||
|
||||
// Execute all agents in this wave in parallel
|
||||
const wavePromises = wave.map(async (agentId) => {
|
||||
try {
|
||||
onProgress?.(agentId, 0, `Starting ${agentId}`);
|
||||
|
||||
const result = await executeAgent(db, agentId, companyId, {
|
||||
onProgress: (progress, action) => {
|
||||
onProgress?.(agentId, progress, action);
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
onAgentComplete?.(result);
|
||||
completed.add(agentId);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
runId: `run-${Date.now()}`,
|
||||
agentId,
|
||||
companyId,
|
||||
output: null,
|
||||
rawResponse: "",
|
||||
status: "failed" as const,
|
||||
error: errorMsg,
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const waveResults = await Promise.all(wavePromises);
|
||||
results.push(...waveResults);
|
||||
|
||||
// Check for failures
|
||||
const hasFailure = waveResults.some((r) => r.status === "failed");
|
||||
if (hasFailure) {
|
||||
console.warn(`[Pipeline] Wave completed with failures`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
8
packages/shared/src/agents/index.ts
Normal file
8
packages/shared/src/agents/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Agents module exports
|
||||
*/
|
||||
|
||||
export * from "./runner.js";
|
||||
export * from "./executor.js";
|
||||
export * from "./eventEmitter.js";
|
||||
export * from "./validationAgents.js";
|
||||
279
packages/shared/src/agents/runner.ts
Normal file
279
packages/shared/src/agents/runner.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Individual agent execution
|
||||
* Runs a single agent with proper context and error handling
|
||||
*/
|
||||
|
||||
import type { Db } from "../db/database.js";
|
||||
import { streamResponse, streamStructuredResponse, isConfigured } from "../llm/client.js";
|
||||
import { getAgentPrompt, getAgentMetadata } from "../llm/prompts.js";
|
||||
import { buildAgentContext } from "../llm/context.js";
|
||||
import {
|
||||
emitAgentProgress,
|
||||
emitAgentStarted,
|
||||
emitAgentCompleted,
|
||||
emitAgentFailed,
|
||||
emitAgentStreaming,
|
||||
} from "./eventEmitter.js";
|
||||
|
||||
export interface AgentRunOptions {
|
||||
onProgress?: (progress: number, action: string) => void;
|
||||
signal?: AbortSignal;
|
||||
structured?: boolean;
|
||||
schema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentRunResult {
|
||||
runId: string;
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
output: unknown;
|
||||
rawResponse: string;
|
||||
status: "completed" | "failed" | "cancelled";
|
||||
error?: string;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single agent
|
||||
*/
|
||||
export async function executeAgent(
|
||||
db: Db,
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
options: AgentRunOptions = {}
|
||||
): Promise<AgentRunResult> {
|
||||
const {
|
||||
onProgress,
|
||||
signal,
|
||||
structured = false,
|
||||
schema,
|
||||
} = options;
|
||||
|
||||
const runId = `run-${Date.now()}`;
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
// Check if LLM is configured
|
||||
if (!isConfigured()) {
|
||||
const errorMsg = "LLM API key is not configured. Please set PI_API_KEY.";
|
||||
console.error(`[Agent] ${errorMsg}`);
|
||||
|
||||
// Update agent run status in database
|
||||
updateAgentRunStatus(db, runId, agentId, companyId, "failed", errorMsg);
|
||||
|
||||
return {
|
||||
runId,
|
||||
agentId,
|
||||
companyId,
|
||||
output: { error: errorMsg },
|
||||
rawResponse: "",
|
||||
status: "failed",
|
||||
error: errorMsg,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Update agent run status to running
|
||||
updateAgentRunStatus(db, runId, agentId, companyId, "running");
|
||||
|
||||
// Get agent metadata
|
||||
const metadata = getAgentMetadata(agentId);
|
||||
const action = metadata?.description || "Running agent";
|
||||
|
||||
// Emit agent started event
|
||||
emitAgentStarted(runId, agentId, companyId);
|
||||
|
||||
onProgress?.(0, action);
|
||||
emitAgentProgress(runId, agentId, companyId, 0, action);
|
||||
|
||||
// Build agent context
|
||||
const context = await buildAgentContext(db, agentId, companyId, {
|
||||
includeHistoricalData: true,
|
||||
includeModel: true,
|
||||
});
|
||||
|
||||
onProgress?.(20, `Gathering data for ${action}`);
|
||||
emitAgentProgress(runId, agentId, companyId, 20, `Gathering data for ${action}`);
|
||||
|
||||
// Get agent prompt
|
||||
const prompt = getAgentPrompt(agentId, context);
|
||||
|
||||
onProgress?.(40, `Executing ${action}`);
|
||||
emitAgentProgress(runId, agentId, companyId, 40, `Executing ${action}`);
|
||||
|
||||
// Execute LLM call
|
||||
let rawResponse = "";
|
||||
let output: unknown;
|
||||
|
||||
if (structured && schema) {
|
||||
output = await streamStructuredResponse(prompt, schema, {
|
||||
onProgress: (text) => {
|
||||
rawResponse += text;
|
||||
// Emit streaming event for live output
|
||||
emitAgentStreaming(runId, agentId, companyId, text);
|
||||
const progress = 40 + Math.min(40, (rawResponse.length / 4000) * 40);
|
||||
onProgress?.(progress, `Processing ${action}`);
|
||||
emitAgentProgress(runId, agentId, companyId, progress, `Processing ${action}`);
|
||||
},
|
||||
signal,
|
||||
});
|
||||
} else {
|
||||
rawResponse = await streamResponse(prompt, {
|
||||
onProgress: (text) => {
|
||||
// Emit streaming event for live output
|
||||
emitAgentStreaming(runId, agentId, companyId, text);
|
||||
const progress = 40 + Math.min(40, (rawResponse.length / 4000) * 40);
|
||||
onProgress?.(progress, `Processing ${action}`);
|
||||
emitAgentProgress(runId, agentId, companyId, progress, `Processing ${action}`);
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
output = JSON.parse(rawResponse);
|
||||
} catch {
|
||||
output = { text: rawResponse };
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(90, `Finalizing ${action}`);
|
||||
emitAgentProgress(runId, agentId, companyId, 90, `Finalizing ${action}`);
|
||||
|
||||
// Store agent output in database
|
||||
storeAgentOutput(db, runId, agentId, companyId, output, rawResponse);
|
||||
|
||||
// Update agent run status to completed
|
||||
updateAgentRunStatus(db, runId, agentId, companyId, "completed");
|
||||
|
||||
onProgress?.(100, `Completed ${action}`);
|
||||
emitAgentProgress(runId, agentId, companyId, 100, `Completed ${action}`);
|
||||
|
||||
// Calculate duration
|
||||
const duration = Date.now() - new Date(startedAt).getTime();
|
||||
|
||||
// Emit completion event
|
||||
emitAgentCompleted(runId, agentId, companyId, output, duration);
|
||||
|
||||
return {
|
||||
runId,
|
||||
agentId,
|
||||
companyId,
|
||||
output,
|
||||
rawResponse,
|
||||
status: "completed",
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Check if cancelled
|
||||
if (signal?.aborted) {
|
||||
updateAgentRunStatus(db, runId, agentId, companyId, "cancelled");
|
||||
emitAgentFailed(runId, agentId, companyId, "Cancelled by user");
|
||||
return {
|
||||
runId,
|
||||
agentId,
|
||||
companyId,
|
||||
output: null,
|
||||
rawResponse: "",
|
||||
status: "cancelled",
|
||||
error: "Cancelled by user",
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Update agent run status to failed
|
||||
updateAgentRunStatus(db, runId, agentId, companyId, "failed", errorMsg);
|
||||
|
||||
// Emit failed event
|
||||
emitAgentFailed(runId, agentId, companyId, errorMsg);
|
||||
|
||||
return {
|
||||
runId,
|
||||
agentId,
|
||||
companyId,
|
||||
output: { error: errorMsg },
|
||||
rawResponse: "",
|
||||
status: "failed",
|
||||
error: errorMsg,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update agent run status in database
|
||||
*/
|
||||
function updateAgentRunStatus(
|
||||
db: Db,
|
||||
runId: string,
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
status: "running" | "completed" | "failed" | "cancelled",
|
||||
error?: string
|
||||
): void {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO agent_runs (id, agent_id, company_id, status, started_at${error ? ", error" : ""})
|
||||
VALUES (?, ?, ?, ?, datetime('now')${error ? ", ?" : ""})
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
completed_at = CASE WHEN excluded.status IN ('completed', 'failed', 'cancelled') THEN datetime('now') ELSE completed_at END,
|
||||
error = COALESCE(excluded.error, error)
|
||||
`);
|
||||
|
||||
if (error) {
|
||||
stmt.run(runId, agentId, companyId, status, error);
|
||||
} else {
|
||||
stmt.run(runId, agentId, companyId, status);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store agent output in database
|
||||
*/
|
||||
function storeAgentOutput(
|
||||
db: Db,
|
||||
runId: string,
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
output: unknown,
|
||||
rawResponse: string
|
||||
): void {
|
||||
// Store output as JSON in the action field for now
|
||||
// In a production system, you'd have a separate table for agent outputs
|
||||
const stmt = db.prepare(`
|
||||
UPDATE agent_runs
|
||||
SET action = ?, progress = 100
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(JSON.stringify({ output, rawResponse: rawResponse.slice(0, 1000) }), runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute agent with simple callback interface
|
||||
*/
|
||||
export async function executeAgentSimple(
|
||||
db: Db,
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
onProgress?: (message: string) => void
|
||||
): Promise<unknown> {
|
||||
const result = await executeAgent(db, agentId, companyId, {
|
||||
onProgress: (progress, action) => {
|
||||
onProgress?.(`[${progress}%] ${action}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (result.status === "failed") {
|
||||
throw new Error(result.error || "Agent execution failed");
|
||||
}
|
||||
|
||||
return result.output;
|
||||
}
|
||||
400
packages/shared/src/agents/validationAgents.ts
Normal file
400
packages/shared/src/agents/validationAgents.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Validation Agent Implementations
|
||||
* These agents perform validation checks on research outputs
|
||||
*/
|
||||
|
||||
import type { Db } from "../db/database.js";
|
||||
import { streamStructuredResponse, completeStructured, isConfigured } from "../llm/client.js";
|
||||
import { getAgentPrompt, type AgentContext } from "../llm/prompts.js";
|
||||
import { buildAgentContext } from "../llm/context.js";
|
||||
import { emitValidationUpdated } from "./eventEmitter.js";
|
||||
|
||||
export interface ValidationResult {
|
||||
passed: boolean;
|
||||
confidence: "high" | "medium" | "low";
|
||||
issues: Array<{
|
||||
severity: "error" | "warning" | "info";
|
||||
message: string;
|
||||
location?: string;
|
||||
suggestion?: string;
|
||||
}>;
|
||||
notes: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source Verification Agent
|
||||
* Verifies that all claims in the memo are properly cited and accurate
|
||||
*/
|
||||
export async function executeSourceVerification(
|
||||
db: Db,
|
||||
companyId: string
|
||||
): Promise<ValidationResult> {
|
||||
if (!isConfigured()) {
|
||||
return {
|
||||
passed: false,
|
||||
confidence: "low",
|
||||
issues: [{ severity: "error", message: "LLM API key not configured" }],
|
||||
notes: "Cannot verify sources without LLM access",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await buildAgentContext(db, "sv", companyId, {
|
||||
includeHistoricalData: true,
|
||||
includeModel: true,
|
||||
});
|
||||
|
||||
const prompt = getAgentPrompt("sv", context);
|
||||
|
||||
const schema = {
|
||||
verificationResults: [
|
||||
{
|
||||
claim: "string",
|
||||
cited: "boolean",
|
||||
sourceType: "string",
|
||||
accuracy: ["high", "medium", "low"],
|
||||
notes: "string",
|
||||
},
|
||||
],
|
||||
flaggedClaims: [
|
||||
{
|
||||
claim: "string",
|
||||
issue: "string",
|
||||
suggestion: "string",
|
||||
},
|
||||
],
|
||||
overallQuality: ["high", "medium", "low"],
|
||||
recommendations: ["string"],
|
||||
};
|
||||
|
||||
const result = await completeStructured(prompt, schema);
|
||||
|
||||
// Extract issues from the result
|
||||
const issues: ValidationResult["issues"] = [];
|
||||
|
||||
for (const flagged of (result as any).flaggedClaims || []) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: flagged.issue,
|
||||
suggestion: flagged.suggestion,
|
||||
});
|
||||
}
|
||||
|
||||
if ((result as any).overallQuality === "low") {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Overall source quality is low. Many claims may be unsupported.",
|
||||
});
|
||||
}
|
||||
|
||||
const passed = issues.filter((i) => i.severity === "error").length === 0;
|
||||
|
||||
// Emit validation updated event
|
||||
emitValidationUpdated(
|
||||
companyId,
|
||||
undefined,
|
||||
passed ? "verified" : "flagged",
|
||||
"sv",
|
||||
`Verified ${((result as any).verificationResults || []).length} claims`
|
||||
);
|
||||
|
||||
return {
|
||||
passed,
|
||||
confidence: (result as any).overallQuality === "high" ? "high" : (result as any).overallQuality === "medium" ? "medium" : "low",
|
||||
issues,
|
||||
notes: (result as any).recommendations?.join(". ") || "",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
passed: false,
|
||||
confidence: "low",
|
||||
issues: [{ severity: "error", message: `Source verification failed: ${errorMsg}` }],
|
||||
notes: errorMsg,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Model QA Agent
|
||||
* Audits the financial model for errors and inconsistencies
|
||||
*/
|
||||
export async function executeModelQA(
|
||||
db: Db,
|
||||
companyId: string
|
||||
): Promise<ValidationResult> {
|
||||
if (!isConfigured()) {
|
||||
return {
|
||||
passed: false,
|
||||
confidence: "low",
|
||||
issues: [{ severity: "error", message: "LLM API key not configured" }],
|
||||
notes: "Cannot audit model without LLM access",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await buildAgentContext(db, "qa", companyId, {
|
||||
includeHistoricalData: true,
|
||||
includeModel: true,
|
||||
});
|
||||
|
||||
const prompt = getAgentPrompt("qa", context);
|
||||
|
||||
const schema = {
|
||||
formulaErrors: [
|
||||
{
|
||||
location: "string",
|
||||
issue: "string",
|
||||
severity: ["error", "warning"],
|
||||
fix: "string",
|
||||
},
|
||||
],
|
||||
consistencyIssues: [
|
||||
{
|
||||
type: "string",
|
||||
description: "string",
|
||||
severity: ["error", "warning"],
|
||||
},
|
||||
],
|
||||
sanityCheckResults: [
|
||||
{
|
||||
check: "string",
|
||||
passed: "boolean",
|
||||
notes: "string",
|
||||
},
|
||||
],
|
||||
recommendations: ["string"],
|
||||
overallStatus: ["pass", "warn", "fail"],
|
||||
};
|
||||
|
||||
const result = await completeStructured(prompt, schema);
|
||||
|
||||
// Extract issues from the result
|
||||
const issues: ValidationResult["issues"] = [];
|
||||
|
||||
for (const error of (result as any).formulaErrors || []) {
|
||||
issues.push({
|
||||
severity: error.severity,
|
||||
message: error.issue,
|
||||
location: error.location,
|
||||
suggestion: error.fix,
|
||||
});
|
||||
}
|
||||
|
||||
for (const issue of (result as any).consistencyIssues || []) {
|
||||
issues.push({
|
||||
severity: issue.severity,
|
||||
message: issue.description,
|
||||
suggestion: `Review ${issue.type} consistency`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const check of (result as any).sanityCheckResults || []) {
|
||||
if (!check.passed) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: `Sanity check failed: ${check.check}`,
|
||||
suggestion: check.notes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const passed = (result as any).overallStatus !== "fail";
|
||||
|
||||
// Emit validation updated event
|
||||
emitValidationUpdated(
|
||||
companyId,
|
||||
"model",
|
||||
passed ? "verified" : (result as any).overallStatus === "warn" ? "flagged" : "failed",
|
||||
"qa",
|
||||
`Model audit ${(result as any).overallStatus}`
|
||||
);
|
||||
|
||||
return {
|
||||
passed,
|
||||
confidence: issues.length === 0 ? "high" : issues.filter((i) => i.severity === "error").length > 0 ? "low" : "medium",
|
||||
issues,
|
||||
notes: (result as any).recommendations?.join(". ") || "",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
passed: false,
|
||||
confidence: "low",
|
||||
issues: [{ severity: "error", message: `Model QA failed: ${errorMsg}` }],
|
||||
notes: errorMsg,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Red Team Agent
|
||||
* Challenges the investment thesis and identifies weaknesses
|
||||
*/
|
||||
export async function executeRedTeam(
|
||||
db: Db,
|
||||
companyId: string
|
||||
): Promise<ValidationResult> {
|
||||
if (!isConfigured()) {
|
||||
return {
|
||||
passed: false,
|
||||
confidence: "low",
|
||||
issues: [{ severity: "error", message: "LLM API key not configured" }],
|
||||
notes: "Cannot perform red team analysis without LLM access",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await buildAgentContext(db, "rt", companyId, {
|
||||
includeHistoricalData: true,
|
||||
includeModel: true,
|
||||
});
|
||||
|
||||
const prompt = getAgentPrompt("rt", context);
|
||||
|
||||
const schema = {
|
||||
counterArguments: [
|
||||
{
|
||||
thesisPoint: "string",
|
||||
counterArgument: "string",
|
||||
severity: ["high", "medium", "low"],
|
||||
},
|
||||
],
|
||||
unstatedAssumptions: [
|
||||
{
|
||||
assumption: "string",
|
||||
risk: "string",
|
||||
},
|
||||
],
|
||||
bearCase: {
|
||||
scenario: "string",
|
||||
probability: ["high", "medium", "low"],
|
||||
keyDrivers: ["string"],
|
||||
},
|
||||
redFlags: [
|
||||
{
|
||||
flag: "string",
|
||||
severity: ["high", "medium", "low"],
|
||||
},
|
||||
],
|
||||
recommendation: ["proceed_with_caution", "acceptable", "needs_revision"],
|
||||
};
|
||||
|
||||
const result = await completeStructured(prompt, schema);
|
||||
|
||||
// Extract issues from the result
|
||||
const issues: ValidationResult["issues"] = [];
|
||||
|
||||
for (const arg of (result as any).counterArguments || []) {
|
||||
issues.push({
|
||||
severity: arg.severity === "high" ? "error" : arg.severity === "medium" ? "warning" : "info",
|
||||
message: `Counter-argument: ${arg.counterArgument}`,
|
||||
suggestion: `Address: ${arg.thesisPoint}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const assumption of (result as any).unstatedAssumptions || []) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: `Unstated assumption: ${assumption.assumption}`,
|
||||
suggestion: assumption.risk,
|
||||
});
|
||||
}
|
||||
|
||||
for (const flag of (result as any).redFlags || []) {
|
||||
issues.push({
|
||||
severity: flag.severity === "high" ? "error" : flag.severity === "medium" ? "warning" : "info",
|
||||
message: `Red flag: ${flag.flag}`,
|
||||
suggestion: "Monitor closely",
|
||||
});
|
||||
}
|
||||
|
||||
const passed = (result as any).recommendation !== "needs_revision";
|
||||
|
||||
// Emit validation updated event
|
||||
emitValidationUpdated(
|
||||
companyId,
|
||||
"thesis",
|
||||
passed ? "verified" : "flagged",
|
||||
"rt",
|
||||
(result as any).recommendation
|
||||
);
|
||||
|
||||
return {
|
||||
passed,
|
||||
confidence: (result as any).recommendation === "acceptable" ? "high" : (result as any).recommendation === "proceed_with_caution" ? "medium" : "low",
|
||||
issues,
|
||||
notes: `Bear case: ${(result as any).bearCase?.scenario || "None identified"}. ${(result as any).redFlags?.length || 0} red flags found.`,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
passed: false,
|
||||
confidence: "low",
|
||||
issues: [{ severity: "error", message: `Red team analysis failed: ${errorMsg}` }],
|
||||
notes: errorMsg,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all validation agents for a company
|
||||
*/
|
||||
export async function runAllValidations(
|
||||
db: Db,
|
||||
companyId: string
|
||||
): Promise<{
|
||||
sourceVerification: ValidationResult;
|
||||
modelQA: ValidationResult;
|
||||
redTeam: ValidationResult;
|
||||
}> {
|
||||
const [sourceVerification, modelQA, redTeam] = await Promise.all([
|
||||
executeSourceVerification(db, companyId),
|
||||
executeModelQA(db, companyId),
|
||||
executeRedTeam(db, companyId),
|
||||
]);
|
||||
|
||||
return { sourceVerification, modelQA, redTeam };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation status for a section
|
||||
*/
|
||||
export function getValidationStatus(
|
||||
validations: {
|
||||
sourceVerification: ValidationResult;
|
||||
modelQA: ValidationResult;
|
||||
redTeam: ValidationResult;
|
||||
},
|
||||
sectionId: string
|
||||
): "verified" | "flagged" | "unverified" | "failed" {
|
||||
// Map sections to relevant validators
|
||||
const validatorMap: Record<string, keyof typeof validations> = {
|
||||
thesis: "redTeam",
|
||||
"variant-perception": "sourceVerification",
|
||||
valuation: "modelQA",
|
||||
model: "modelQA",
|
||||
"financial-summary": "modelQA",
|
||||
risks: "redTeam",
|
||||
};
|
||||
|
||||
const validatorKey = validatorMap[sectionId];
|
||||
if (!validatorKey) return "unverified";
|
||||
|
||||
const validation = validations[validatorKey];
|
||||
if (!validation) return "unverified";
|
||||
|
||||
if (validation.passed) return "verified";
|
||||
if (validation.issues.some((i) => i.severity === "error")) return "failed";
|
||||
return "flagged";
|
||||
}
|
||||
160
packages/shared/src/data/earnings.ts
Normal file
160
packages/shared/src/data/earnings.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Earnings calendar integration
|
||||
* Uses Yahoo Finance earnings data
|
||||
*/
|
||||
|
||||
export interface EarningsDate {
|
||||
ticker: string;
|
||||
quarter: string;
|
||||
earningsDate: Date | null;
|
||||
estimatedEPS?: number;
|
||||
actualEPS?: number;
|
||||
estimatedRevenue?: number;
|
||||
actualRevenue?: number;
|
||||
fiscalQuarterEnding?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get earnings date for a specific ticker
|
||||
*/
|
||||
export async function getEarningsDate(ticker: string): Promise<Date | null> {
|
||||
try {
|
||||
const normalizedTicker = ticker.toUpperCase();
|
||||
const url = `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${normalizedTicker}?modules=earnings earningsTrend earningsForecast`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const result = data.quoteSummary?.result?.[0];
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check earningsChart for next earnings date
|
||||
const earningsChart = result.earningsChart;
|
||||
if (earningsChart?.currentQuarterEstimateDate) {
|
||||
return new Date(earningsChart.currentQuarterEstimateDate * 1000);
|
||||
}
|
||||
|
||||
// Try earningsTrend for next quarter
|
||||
const earningsTrend = result.earningsTrend;
|
||||
if (earningsTrend?.trend?.[0]?.endDate) {
|
||||
return new Date(earningsTrend.trend[0].endDate);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`[Earnings] Error fetching earnings date for ${ticker}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get earnings history for a ticker
|
||||
*/
|
||||
export async function getEarningsHistory(ticker: string, quarters: number = 4): Promise<EarningsDate[]> {
|
||||
try {
|
||||
const normalizedTicker = ticker.toUpperCase();
|
||||
const url = `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${normalizedTicker}?modules=earningsHistory`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const result = data.quoteSummary?.result?.[0];
|
||||
|
||||
if (!result?.earningsHistory?.history) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.earningsHistory.history
|
||||
.slice(0, quarters)
|
||||
.map((h: any) => ({
|
||||
ticker: normalizedTicker,
|
||||
quarter: h.quarter || "",
|
||||
earningsDate: h.earningsDate ? new Date(h.earningsDate * 1000) : null,
|
||||
estimatedEPS: h.epsEstimate,
|
||||
actualEPS: h.epsActual,
|
||||
estimatedRevenue: h.revenueEstimate,
|
||||
actualRevenue: h.revenueActual,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[Earnings] Error fetching earnings history for ${ticker}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get earnings calendar for multiple companies
|
||||
*/
|
||||
export async function getEarningsCalendar(tickers: string[]): Promise<Record<string, EarningsDate | null>> {
|
||||
const results: Record<string, EarningsDate | null> = {};
|
||||
|
||||
const batchSize = 5;
|
||||
for (let i = 0; i < tickers.length; i += batchSize) {
|
||||
const batch = tickers.slice(i, i + batchSize);
|
||||
const promises = batch.map(async (ticker) => {
|
||||
const date = await getEarningsDate(ticker);
|
||||
if (date) {
|
||||
results[ticker.toUpperCase()] = {
|
||||
ticker: ticker.toUpperCase(),
|
||||
quarter: "Next",
|
||||
earningsDate: date,
|
||||
};
|
||||
}
|
||||
});
|
||||
await Promise.all(promises);
|
||||
|
||||
if (i + batchSize < tickers.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format quarter string from date
|
||||
*/
|
||||
export function getQuarterString(date: Date): string {
|
||||
const month = date.getMonth();
|
||||
const year = date.getFullYear();
|
||||
const quarter = Math.floor(month / 3) + 1;
|
||||
return `Q${quarter} ${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if earnings date is upcoming (within next 30 days)
|
||||
*/
|
||||
export function isUpcomingEarnings(date: Date | null): boolean {
|
||||
if (!date) return false;
|
||||
const now = new Date();
|
||||
const daysUntilEarnings = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
return daysUntilEarnings >= 0 && daysUntilEarnings <= 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get earnings timing (BMO/AMC)
|
||||
*/
|
||||
export function getEarningsTiming(date: Date): "bmo" | "amc" | "unknown" {
|
||||
// Default to AMC for most tech companies, BMO for others
|
||||
// In a real implementation, you'd fetch this from earnings data
|
||||
const hour = date.getHours();
|
||||
return hour < 12 ? "bmo" : "amc";
|
||||
}
|
||||
8
packages/shared/src/data/index.ts
Normal file
8
packages/shared/src/data/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Data module exports
|
||||
* Provides integration with external data sources
|
||||
*/
|
||||
|
||||
export * from "./sec.js";
|
||||
export * from "./market.js";
|
||||
export * from "./earnings.js";
|
||||
268
packages/shared/src/data/market.ts
Normal file
268
packages/shared/src/data/market.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Market data integration using Yahoo Finance
|
||||
* Note: This is a simplified implementation for demonstration
|
||||
* In production, you'd use a proper market data API or the Yahoo Finance API
|
||||
*/
|
||||
|
||||
export interface PricePoint {
|
||||
date: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface Quote {
|
||||
ticker: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
previousClose: number;
|
||||
dayHigh: number;
|
||||
dayLow: number;
|
||||
volume: number;
|
||||
marketCap?: number;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current quote for a ticker
|
||||
* Uses Yahoo Finance's unofficial API
|
||||
*/
|
||||
export async function fetchQuote(ticker: string): Promise<Quote | null> {
|
||||
try {
|
||||
const normalizedTicker = ticker.toUpperCase();
|
||||
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${normalizedTicker}?interval=1d&range=1d`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const result = data.chart?.result?.[0];
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta = result.meta;
|
||||
const quote = result.indicators?.quote?.[0];
|
||||
const prevClose = meta?.previousClose || quote?.close?.[0] || 0;
|
||||
const currentPrice = meta?.regularMarketPrice || quote?.close?.[quote.close.length - 1] || 0;
|
||||
|
||||
if (!currentPrice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const change = currentPrice - prevClose;
|
||||
const changePercent = prevClose > 0 ? (change / prevClose) * 100 : 0;
|
||||
|
||||
return {
|
||||
ticker: normalizedTicker,
|
||||
price: currentPrice,
|
||||
change,
|
||||
changePercent,
|
||||
previousClose: prevClose,
|
||||
dayHigh: meta?.regularMarketDayHigh || currentPrice,
|
||||
dayLow: meta?.regularMarketDayLow || currentPrice,
|
||||
volume: meta?.regularMarketVolume || 0,
|
||||
marketCap: meta?.marketCap,
|
||||
currency: meta?.currency || "USD",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Market] Error fetching quote for ${ticker}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch historical price data
|
||||
*/
|
||||
export async function fetchPriceHistory(
|
||||
ticker: string,
|
||||
period: "1d" | "5d" | "1mo" | "3mo" | "6mo" | "1y" | "2y" | "5y" | "max" = "1y"
|
||||
): Promise<PricePoint[]> {
|
||||
try {
|
||||
const normalizedTicker = ticker.toUpperCase();
|
||||
const interval = period === "1d" ? "5m" : period === "5d" ? "15m" : "1d";
|
||||
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${normalizedTicker}?interval=${interval}&range=${period}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const result = data.chart?.result?.[0];
|
||||
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const timestamps = result.timestamp || [];
|
||||
const quote = result.indicators?.quote?.[0];
|
||||
|
||||
if (!quote) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const points: PricePoint[] = [];
|
||||
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
const open = quote.open?.[i];
|
||||
const high = quote.high?.[i];
|
||||
const low = quote.low?.[i];
|
||||
const close = quote.close?.[i];
|
||||
const volume = quote.volume?.[i];
|
||||
|
||||
if (open != null && high != null && low != null && close != null) {
|
||||
points.push({
|
||||
date: new Date(timestamps[i] * 1000).toISOString().split("T")[0],
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume: volume || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
} catch (error) {
|
||||
console.error(`[Market] Error fetching price history for ${ticker}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch multiple quotes at once
|
||||
*/
|
||||
export async function fetchQuotes(tickers: string[]): Promise<Record<string, Quote>> {
|
||||
const results: Record<string, Quote> = {};
|
||||
|
||||
// Batch requests to avoid rate limiting
|
||||
const batchSize = 5;
|
||||
for (let i = 0; i < tickers.length; i += batchSize) {
|
||||
const batch = tickers.slice(i, i + batchSize);
|
||||
const promises = batch.map(async (ticker) => {
|
||||
const quote = await fetchQuote(ticker);
|
||||
if (quote) {
|
||||
results[ticker.toUpperCase()] = quote;
|
||||
}
|
||||
});
|
||||
await Promise.all(promises);
|
||||
|
||||
// Small delay between batches
|
||||
if (i + batchSize < tickers.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for stocks by symbol or name
|
||||
* This is a simplified search that returns common stock matches
|
||||
*/
|
||||
export async function searchStocks(query: string): Promise<Array<{ ticker: string; name: string; exchange: string }>> {
|
||||
try {
|
||||
const url = `https://query1.finance.yahoo.com/v1/finance/search?q=${encodeURIComponent(query)}"esCount=10&newsCount=0`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const quotes = data.quotes || [];
|
||||
|
||||
return quotes
|
||||
.filter((q: { quoteType?: string; isYahooFinance?: boolean }) =>
|
||||
q.quoteType === "EQUITY" || q.isYahooFinance === true
|
||||
)
|
||||
.map((q: { symbol: string; longname?: string; shortname?: string; exchange: string }) => ({
|
||||
ticker: q.symbol.toUpperCase(),
|
||||
name: q.longname || q.shortname || q.symbol,
|
||||
exchange: q.exchange,
|
||||
}))
|
||||
.slice(0, 10);
|
||||
} catch (error) {
|
||||
console.error(`[Market] Error searching for "${query}":`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get company profile info
|
||||
*/
|
||||
export interface CompanyProfile {
|
||||
ticker: string;
|
||||
name: string;
|
||||
sector?: string;
|
||||
industry?: string;
|
||||
description?: string;
|
||||
website?: string;
|
||||
employees?: number;
|
||||
marketCap?: number;
|
||||
exchange?: string;
|
||||
}
|
||||
|
||||
export async function getCompanyProfile(ticker: string): Promise<CompanyProfile | null> {
|
||||
try {
|
||||
const normalizedTicker = ticker.toUpperCase();
|
||||
const url = `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${normalizedTicker}?modules=summaryProfile assetProfile`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const result = data.quoteSummary?.result?.[0];
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = result.summaryProfile || {};
|
||||
const asset = result.assetProfile || {};
|
||||
|
||||
return {
|
||||
ticker: normalizedTicker,
|
||||
name: profile.quoteType?.shortName || normalizedTicker,
|
||||
sector: profile.sector,
|
||||
industry: profile.industry,
|
||||
description: profile.longBusinessSummary,
|
||||
website: profile.website,
|
||||
employees: profile.fullTimeEmployees,
|
||||
marketCap: profile.marketCap,
|
||||
exchange: asset.exchangeName || "NASDAQ",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Market] Error fetching profile for ${ticker}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
306
packages/shared/src/data/sec.ts
Normal file
306
packages/shared/src/data/sec.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* SEC EDGAR API client for fetching company filings and data
|
||||
* https://www.sec.gov/edgar/sec-api-documentation
|
||||
*/
|
||||
|
||||
const EDGAR_BASE_URL = "https://www.sec.gov/Archives/edgar/data";
|
||||
const EDGAR_SEARCH_API = "https://www.sec.gov/Archives/edgar/data";
|
||||
|
||||
// Rate limiting: SEC requires 10 requests/second max
|
||||
const requestQueue: Array<() => Promise<void>> = [];
|
||||
let isProcessing = false;
|
||||
const REQUEST_DELAY = 110; // 110ms between requests = ~9 req/sec
|
||||
|
||||
async function rateLimit<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
requestQueue.push(async () => {
|
||||
try {
|
||||
const result = await fn();
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
if (isProcessing || requestQueue.length === 0) return;
|
||||
isProcessing = true;
|
||||
const fn = requestQueue.shift();
|
||||
if (fn) {
|
||||
await fn();
|
||||
setTimeout(() => {
|
||||
isProcessing = false;
|
||||
processQueue();
|
||||
}, REQUEST_DELAY);
|
||||
} else {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SECFiling {
|
||||
id: string;
|
||||
formType: string;
|
||||
filedDate: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface SECCompanyInfo {
|
||||
cik: string;
|
||||
ticker: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
sic?: string;
|
||||
stateOfIncorporation?: string;
|
||||
businessAddress?: {
|
||||
street1?: string;
|
||||
street2?: string;
|
||||
city?: string;
|
||||
stateOrCountry?: string;
|
||||
zipCode?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the User-Agent header required by SEC
|
||||
* SEC requires a contact email in the User-Agent
|
||||
*/
|
||||
function getHeaders(): HeadersInit {
|
||||
const userEmail = process.env.SEC_USER_AGENT || "your-email@example.com";
|
||||
return {
|
||||
"User-Agent": `MosaicIQ Research App (${userEmail})`,
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
"Accept": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up company CIK by ticker symbol
|
||||
*/
|
||||
export async function lookupCIK(ticker: string): Promise<string | null> {
|
||||
try {
|
||||
const url = `https://www.sec.gov/files/company_tickers.json`;
|
||||
const response = await rateLimit(() => fetch(url, { headers: getHeaders() }));
|
||||
const data = await response.json();
|
||||
|
||||
// The SEC returns a flat object with 0, 1, 2... keys
|
||||
for (const key of Object.keys(data)) {
|
||||
const entry = data[key];
|
||||
if (entry.ticker.toLowerCase() === ticker.toLowerCase()) {
|
||||
return String(entry.cik_str);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`[SEC] Error looking up CIK for ${ticker}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch company information from SEC submissions
|
||||
*/
|
||||
export async function getCompanyInfo(ticker: string): Promise<SECCompanyInfo | null> {
|
||||
try {
|
||||
const cik = await lookupCIK(ticker);
|
||||
if (!cik) return null;
|
||||
|
||||
// Pad CIK to 10 digits with leading zeros
|
||||
const paddedCik = cik.padStart(10, "0");
|
||||
|
||||
const url = `${EDGAR_BASE_URL}/${paddedCik}/index.json`;
|
||||
const response = await rateLimit(() => fetch(url, { headers: getHeaders() }));
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Extract company info from the directory structure
|
||||
const companyInfo: SECCompanyInfo = {
|
||||
cik,
|
||||
ticker: ticker.toUpperCase(),
|
||||
name: data.company?.name || ticker.toUpperCase(),
|
||||
};
|
||||
|
||||
return companyInfo;
|
||||
} catch (error) {
|
||||
console.error(`[SEC] Error fetching company info for ${ticker}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch filings for a company
|
||||
*/
|
||||
export async function fetchFilings(
|
||||
ticker: string,
|
||||
options: {
|
||||
formTypes?: string[];
|
||||
limit?: number;
|
||||
since?: string;
|
||||
} = {}
|
||||
): Promise<SECFiling[]> {
|
||||
try {
|
||||
const cik = await lookupCIK(ticker);
|
||||
if (!cik) return [];
|
||||
|
||||
const paddedCik = cik.padStart(10, "0");
|
||||
const url = `${EDGAR_BASE_URL}/${paddedCik}/index.json`;
|
||||
const response = await rateLimit(() => fetch(url, { headers: getHeaders() }));
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const filings = data.directory?.item || [];
|
||||
|
||||
let filtered = filings.filter((f: { type: string }) => {
|
||||
// Only include common filing types
|
||||
const commonTypes = ["10-K", "10-Q", "8-K", "10-K/A", "10-Q/A", "8-K/A", "DEF 14A", "13F-HR"];
|
||||
return commonTypes.includes(f.type);
|
||||
});
|
||||
|
||||
// Filter by form types if specified
|
||||
if (options.formTypes && options.formTypes.length > 0) {
|
||||
filtered = filtered.filter((f: { type: string }) =>
|
||||
options.formTypes!.includes(f.type)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by date if specified
|
||||
if (options.since) {
|
||||
const sinceDate = new Date(options.since);
|
||||
filtered = filtered.filter((f: { last_modified: string }) => {
|
||||
const filedDate = new Date(f.last_modified);
|
||||
return filedDate >= sinceDate;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by date descending and limit
|
||||
filtered.sort((a: { last_modified: string }, b: { last_modified: string }) =>
|
||||
new Date(b.last_modified).getTime() - new Date(a.last_modified).getTime()
|
||||
);
|
||||
|
||||
if (options.limit) {
|
||||
filtered = filtered.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return filtered.map((f: { type: string; name: string; last_modified: string }) => ({
|
||||
id: `${ticker}-${f.type}-${f.last_modified}`,
|
||||
formType: f.type,
|
||||
filedDate: f.last_modified,
|
||||
title: `${f.type} - ${new Date(f.last_modified).toLocaleDateString()}`,
|
||||
url: `${EDGAR_BASE_URL}/${paddedCik}/${f.name}`,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[SEC] Error fetching filings for ${ticker}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch filing content as text
|
||||
*/
|
||||
export async function fetchFilingContent(url: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await rateLimit(() => fetch(url, { headers: getHeaders() }));
|
||||
if (!response.ok) return null;
|
||||
|
||||
const text = await response.text();
|
||||
return text;
|
||||
} catch (error) {
|
||||
console.error("[SEC] Error fetching filing content:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search companies by query
|
||||
*/
|
||||
export async function searchCompanies(query: string): Promise<Array<{ ticker: string; name: string; cik: string }>> {
|
||||
try {
|
||||
// Try to parse as ticker first
|
||||
if (query.length <= 5 && query.toUpperCase() === query) {
|
||||
const cik = await lookupCIK(query);
|
||||
if (cik) {
|
||||
const info = await getCompanyInfo(query);
|
||||
if (info) {
|
||||
return [{ ticker: info.ticker, name: info.name, cik }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For broader search, we'd need a different API or service
|
||||
// For now, return empty and suggest using ticker lookup
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`[SEC] Error searching for "${query}":`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse key sections from a 10-K filing
|
||||
*/
|
||||
export interface Parsed10K {
|
||||
business: string;
|
||||
riskFactors: string;
|
||||
financialStatements: string;
|
||||
managementDiscussion: string;
|
||||
}
|
||||
|
||||
export async function parse10K(filingUrl: string): Promise<Parsed10K | null> {
|
||||
try {
|
||||
const content = await fetchFilingContent(filingUrl);
|
||||
if (!content) return null;
|
||||
|
||||
// This is a simplified parser - a real implementation would use XBRL parsing
|
||||
const result: Parsed10K = {
|
||||
business: extractSection(content, ["ITEM 1", "Item 1"], ["ITEM 1A", "Item 1A", "ITEM 2", "Item 2"]),
|
||||
riskFactors: extractSection(content, ["ITEM 1A", "Item 1A"], ["ITEM 1B", "Item 1B", "ITEM 2", "Item 2"]),
|
||||
financialStatements: extractSection(content, ["ITEM 8", "Item 8"], ["ITEM 9", "Item 9"]),
|
||||
managementDiscussion: extractSection(content, ["ITEM 7", "Item 7"], ["ITEM 7A", "Item 7A", "ITEM 8", "Item 8"]),
|
||||
};
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("[SEC] Error parsing 10-K:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractSection(content: string, startPatterns: string[], endPatterns: string[]): string {
|
||||
const lines = content.split("\n");
|
||||
|
||||
let startIndex = -1;
|
||||
let endIndex = lines.length;
|
||||
|
||||
for (const pattern of startPatterns) {
|
||||
const idx = lines.findIndex((line) =>
|
||||
line.toUpperCase().includes(pattern.toUpperCase())
|
||||
);
|
||||
if (idx !== -1) {
|
||||
startIndex = Math.max(startIndex, idx);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of endPatterns) {
|
||||
const idx = lines.findIndex((line) =>
|
||||
line.toUpperCase().includes(pattern.toUpperCase())
|
||||
);
|
||||
if (idx !== -1) {
|
||||
endIndex = Math.min(endIndex, idx);
|
||||
}
|
||||
}
|
||||
|
||||
if (startIndex === -1) return "";
|
||||
|
||||
return lines.slice(startIndex, endIndex).join("\n").trim();
|
||||
}
|
||||
18
packages/shared/src/db/database.test.ts
Normal file
18
packages/shared/src/db/database.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initDatabase, closeDatabase } from "./database.js";
|
||||
import { SCHEMA_VERSION } from "./schema.js";
|
||||
import { getPortfolio } from "./queries.js";
|
||||
|
||||
describe("database initialization", () => {
|
||||
it("initializes schema, default portfolio, foreign keys, and version", () => {
|
||||
const db = initDatabase({ inMemory: true });
|
||||
try {
|
||||
const version = db.prepare("SELECT value FROM _meta WHERE key = 'schema_version'").get() as { value: string };
|
||||
expect(version.value).toBe(String(SCHEMA_VERSION));
|
||||
expect(getPortfolio(db)?.id).toBe("default");
|
||||
expect(db.pragma("foreign_keys", { simple: true })).toBe(1);
|
||||
} finally {
|
||||
closeDatabase(db);
|
||||
}
|
||||
});
|
||||
});
|
||||
119
packages/shared/src/db/database.ts
Normal file
119
packages/shared/src/db/database.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Database connection and initialization for MosaicIQ
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { SCHEMA_VERSION, SQL_SCHEMA, DEFAULT_PORTFOLIO_ID } from "./schema.js";
|
||||
|
||||
export type Db = Database.Database;
|
||||
|
||||
export interface DatabaseConfig {
|
||||
path?: string;
|
||||
inMemory?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default data directory for the platform
|
||||
*/
|
||||
export function getDataDir(): string {
|
||||
const platform = process.platform;
|
||||
const base = platform === "darwin"
|
||||
? `${process.env.HOME}/Documents/MosaicIQ`
|
||||
: platform === "win32"
|
||||
? `${process.env.LOCALAPPDATA}\\MosaicIQ`
|
||||
: `${process.env.HOME}/.local/share/mosaiciq`;
|
||||
|
||||
if (!existsSync(base)) {
|
||||
mkdirSync(base, { recursive: true });
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default database path
|
||||
*/
|
||||
export function getDefaultDbPath(): string {
|
||||
return `${getDataDir()}/mosaiciq.db`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database with schema
|
||||
*/
|
||||
export function initDatabase(config: DatabaseConfig = {}): Db {
|
||||
const dbPath = config.inMemory ? ":memory:" : (config.path ?? getDefaultDbPath());
|
||||
|
||||
if (!config.inMemory && dbPath !== ":memory:") {
|
||||
const dbDir = dirname(dbPath);
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Enable WAL mode for better concurrent access
|
||||
db.pragma("journal_mode = WAL");
|
||||
|
||||
// Enable foreign keys
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
// Check if schema exists
|
||||
const hasMeta = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '_meta'").get();
|
||||
const version = hasMeta
|
||||
? db.prepare("SELECT value FROM _meta WHERE key = 'schema_version'").get() as { value: string } | undefined
|
||||
: undefined;
|
||||
|
||||
if (!version) {
|
||||
// Fresh database - create schema
|
||||
db.exec(SQL_SCHEMA);
|
||||
|
||||
// Set schema version
|
||||
db.prepare("INSERT INTO _meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
|
||||
|
||||
// Create default portfolio
|
||||
db.prepare(`
|
||||
INSERT INTO portfolios (id, name)
|
||||
VALUES (?, ?)
|
||||
`).run(DEFAULT_PORTFOLIO_ID, "Core Retail Coverage");
|
||||
|
||||
console.log(`[DB] Initialized database at ${dbPath}`);
|
||||
} else {
|
||||
const currentVersion = Number(version.value);
|
||||
if (currentVersion !== SCHEMA_VERSION) {
|
||||
console.warn(`[DB] Schema version mismatch: expected ${SCHEMA_VERSION}, got ${currentVersion}`);
|
||||
// TODO: Run migrations
|
||||
}
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
export function closeDatabase(db: Db): void {
|
||||
db.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a database connection (singleton pattern)
|
||||
*/
|
||||
let dbInstance: Db | null = null;
|
||||
|
||||
export function getDatabase(config?: DatabaseConfig): Db {
|
||||
if (!dbInstance) {
|
||||
dbInstance = initDatabase(config);
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
export function resetDatabase(): void {
|
||||
if (dbInstance) {
|
||||
closeDatabase(dbInstance);
|
||||
dbInstance = null;
|
||||
}
|
||||
}
|
||||
9
packages/shared/src/db/index.ts
Normal file
9
packages/shared/src/db/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Database module exports
|
||||
*/
|
||||
|
||||
export * from "./database.js";
|
||||
export * from "./schema.js";
|
||||
export * from "./queries.js";
|
||||
export * from "./seed.js";
|
||||
export * from "./rpcHandler.js";
|
||||
50
packages/shared/src/db/queries.test.ts
Normal file
50
packages/shared/src/db/queries.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { closeDatabase, initDatabase } from "./database.js";
|
||||
import { getClientSettings, getModel, updateModelCell } from "./queries.js";
|
||||
|
||||
function withDb(test: (db: ReturnType<typeof initDatabase>) => void) {
|
||||
const db = initDatabase({ inMemory: true });
|
||||
try {
|
||||
test(db);
|
||||
} finally {
|
||||
closeDatabase(db);
|
||||
}
|
||||
}
|
||||
|
||||
function insertModel(db: ReturnType<typeof initDatabase>) {
|
||||
db.prepare("INSERT INTO companies (id, ticker, name, sector) VALUES ('cost', 'COST', 'Costco', 'Retail')").run();
|
||||
db.prepare("INSERT INTO models (id, company_id, tab) VALUES ('model-1', 'cost', 'base')").run();
|
||||
db.prepare("INSERT INTO model_headers (model_id, position, label) VALUES ('model-1', 0, 'FY25')").run();
|
||||
db.prepare("INSERT INTO model_rows (model_id, position, label, kind, \"values\") VALUES ('model-1', 0, 'Revenue', 'actual', ?)").run(
|
||||
JSON.stringify(["1", "2"])
|
||||
);
|
||||
}
|
||||
|
||||
describe("queries", () => {
|
||||
it("returns parsed model values", () => withDb((db) => {
|
||||
insertModel(db);
|
||||
expect(getModel(db, "cost", "base").rows[0]?.values).toEqual(["1", "2"]);
|
||||
}));
|
||||
|
||||
it("falls back for malformed model JSON values", () => withDb((db) => {
|
||||
insertModel(db);
|
||||
db.prepare("UPDATE model_rows SET \"values\" = '{bad json' WHERE model_id = 'model-1'").run();
|
||||
expect(getModel(db, "cost", "base").rows[0]?.values).toEqual([]);
|
||||
}));
|
||||
|
||||
it("rejects out-of-range model cell updates", () => withDb((db) => {
|
||||
insertModel(db);
|
||||
expect(updateModelCell(db, "cost", "base", 0, 4, "42").ok).toBe(false);
|
||||
expect(updateModelCell(db, "cost", "base", -1, 0, "42").ok).toBe(false);
|
||||
}));
|
||||
|
||||
it("ignores malformed client settings and validates known fields", () => withDb((db) => {
|
||||
db.prepare("INSERT INTO client_settings (key, value) VALUES ('theme', ?)").run(JSON.stringify("dark"));
|
||||
db.prepare("INSERT INTO client_settings (key, value) VALUES ('sidebarWidth', ?)").run(JSON.stringify(20));
|
||||
db.prepare("INSERT INTO client_settings (key, value) VALUES ('density', ?)").run("{bad json");
|
||||
const settings = getClientSettings(db);
|
||||
expect(settings.theme).toBe("dark");
|
||||
expect(settings.sidebarWidth).toBe(240);
|
||||
expect(settings.density).toBe("comfortable");
|
||||
}));
|
||||
});
|
||||
727
packages/shared/src/db/queries.ts
Normal file
727
packages/shared/src/db/queries.ts
Normal file
@@ -0,0 +1,727 @@
|
||||
/**
|
||||
* Prepared SQL queries for MosaicIQ database operations
|
||||
* These provide type-safe access to the database
|
||||
*/
|
||||
|
||||
import type { Db } from "./database.js";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
Company,
|
||||
Holding,
|
||||
Agent,
|
||||
WorkspaceSection,
|
||||
Catalyst,
|
||||
Alert,
|
||||
Risk,
|
||||
EarningsSchedule,
|
||||
Filing,
|
||||
ModelRow,
|
||||
MemoSection,
|
||||
MemoCitation,
|
||||
MemoAnnotation,
|
||||
MemoSectionReview,
|
||||
Snapshot,
|
||||
ExportRecord,
|
||||
ClientSettings,
|
||||
ServerSettings,
|
||||
} from "@mosaiciq/contracts/rpc";
|
||||
import { ClientSettingsSchema, ServerSettingsSchema } from "@mosaiciq/contracts/rpcSchemas";
|
||||
|
||||
export function parseJsonWithSchema<T>(
|
||||
value: string,
|
||||
schema: z.ZodType<T>,
|
||||
fallback: T
|
||||
): T {
|
||||
try {
|
||||
return schema.parse(JSON.parse(value));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Portfolio ==============
|
||||
|
||||
export function getPortfolio(db: Db) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT p.id, p.name, p.active_company_id as activeCompanyId,
|
||||
json_group_array(
|
||||
json_object(
|
||||
'ticker', h.ticker,
|
||||
'name', h.name,
|
||||
'price', h.price,
|
||||
'changePct', h.change_pct,
|
||||
'weight', h.weight
|
||||
)
|
||||
) as holdings
|
||||
FROM portfolios p
|
||||
LEFT JOIN holdings h ON p.id = h.portfolio_id
|
||||
WHERE p.id = ?
|
||||
GROUP BY p.id
|
||||
`);
|
||||
|
||||
const row = stmt.get("default") as {
|
||||
id: string;
|
||||
name: string;
|
||||
activeCompanyId: string | null;
|
||||
holdings: string;
|
||||
} | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
activeCompanyId: row.activeCompanyId ?? "",
|
||||
holdings: row.holdings ? JSON.parse(row.holdings).filter((h: Holding) => h.ticker) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function addHolding(db: Db, portfolioId: string, holding: Holding) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO holdings (portfolio_id, ticker, name, price, change_pct, weight)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
return stmt.run(portfolioId, holding.ticker, holding.name, holding.price, holding.changePct, holding.weight);
|
||||
}
|
||||
|
||||
export function removeHolding(db: Db, portfolioId: string, ticker: string) {
|
||||
const stmt = db.prepare("DELETE FROM holdings WHERE portfolio_id = ? AND ticker = ?");
|
||||
return stmt.run(portfolioId, ticker);
|
||||
}
|
||||
|
||||
export function setActiveCompany(db: Db, portfolioId: string, companyId: string) {
|
||||
const stmt = db.prepare("UPDATE portfolios SET active_company_id = ? WHERE id = ?");
|
||||
return stmt.run(companyId, portfolioId);
|
||||
}
|
||||
|
||||
// ============== Companies ==============
|
||||
|
||||
export function getCompanyByTicker(db: Db, ticker: string): Company | null {
|
||||
const stmt = db.prepare(`
|
||||
SELECT id, ticker, name, sector, sub_industry as subIndustry,
|
||||
price as price, change_pct as changePct, thesis,
|
||||
founded, headquarters, employees
|
||||
FROM companies
|
||||
WHERE ticker = ?
|
||||
`);
|
||||
return stmt.get(ticker.toUpperCase()) as Company | null;
|
||||
}
|
||||
|
||||
export function getCompany(db: Db, companyId: string): Company | null {
|
||||
const stmt = db.prepare(`
|
||||
SELECT id, ticker, name, sector, sub_industry as subIndustry,
|
||||
price as price, change_pct as changePct, thesis,
|
||||
founded, headquarters, employees
|
||||
FROM companies
|
||||
WHERE id = ?
|
||||
`);
|
||||
return stmt.get(companyId) as Company | null;
|
||||
}
|
||||
|
||||
export function searchCompanies(db: Db, query: string): Array<{ ticker: string; name: string; sector: string }> {
|
||||
const stmt = db.prepare(`
|
||||
SELECT ticker, name, sector
|
||||
FROM companies
|
||||
WHERE ticker LIKE ? OR name LIKE ? OR sector LIKE ?
|
||||
ORDER BY
|
||||
CASE WHEN ticker LIKE ? THEN 1 ELSE 2 END,
|
||||
ticker
|
||||
LIMIT 50
|
||||
`);
|
||||
const pattern = `%${query}%`;
|
||||
return stmt.all(pattern, pattern, pattern, pattern) as Array<{ ticker: string; name: string; sector: string }>;
|
||||
}
|
||||
|
||||
export function upsertCompany(db: Db, company: Omit<Company, "id"> & { id?: string }) {
|
||||
const id = company.id ?? `company-${Date.now()}`;
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO companies (id, ticker, name, sector, sub_industry, price, change_pct, thesis, founded, headquarters, employees)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(ticker) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
sector = excluded.sector,
|
||||
sub_industry = excluded.sub_industry,
|
||||
price = excluded.price,
|
||||
change_pct = excluded.change_pct,
|
||||
thesis = excluded.thesis,
|
||||
founded = excluded.founded,
|
||||
headquarters = excluded.headquarters,
|
||||
employees = excluded.employees,
|
||||
updated_at = datetime('now')
|
||||
`);
|
||||
return stmt.run(
|
||||
id,
|
||||
company.ticker,
|
||||
company.name,
|
||||
company.sector,
|
||||
company.subIndustry ?? null,
|
||||
company.price,
|
||||
company.changePct,
|
||||
company.thesis,
|
||||
company.founded ?? null,
|
||||
company.headquarters ?? null,
|
||||
company.employees ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// ============== Workspace ==============
|
||||
|
||||
export function getWorkspaceSection(db: Db, companyId: string, section: string): WorkspaceSection | null {
|
||||
const stmt = db.prepare(`
|
||||
SELECT id, title, content, validation_state as validationState, source_agent as sourceAgent
|
||||
FROM workspace_sections
|
||||
WHERE company_id = ? AND title = ?
|
||||
`);
|
||||
return stmt.get(companyId, section) as WorkspaceSection | null;
|
||||
}
|
||||
|
||||
export function listWorkspaceSources(db: Db, companyId: string) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT type, title, metadata FROM workspace_sources
|
||||
WHERE company_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
return stmt.all(companyId);
|
||||
}
|
||||
|
||||
// ============== Catalysts ==============
|
||||
|
||||
export function listCatalysts(db: Db, companyId: string): Catalyst[] {
|
||||
const stmt = db.prepare(`
|
||||
SELECT id, company_id as companyId, date, event, impact as impact,
|
||||
thesis_relevance as thesisRelevance, source
|
||||
FROM catalysts
|
||||
WHERE company_id = ?
|
||||
ORDER BY date DESC
|
||||
`);
|
||||
return stmt.all(companyId) as Catalyst[];
|
||||
}
|
||||
|
||||
// ============== Alerts ==============
|
||||
|
||||
export function listAlerts(db: Db, companyId?: string, since?: string): Alert[] {
|
||||
let query = "SELECT id, company_id as companyId, timestamp, type, description, thesis_impact as thesisImpact, status, target_section as targetSection FROM alerts WHERE 1=1";
|
||||
const params: (string | undefined)[] = [];
|
||||
|
||||
if (companyId) {
|
||||
query += " AND company_id = ?";
|
||||
params.push(companyId);
|
||||
}
|
||||
if (since) {
|
||||
query += " AND timestamp > ?";
|
||||
params.push(since);
|
||||
}
|
||||
|
||||
query += " ORDER BY timestamp DESC";
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
return stmt.all(...params) as Alert[];
|
||||
}
|
||||
|
||||
// ============== Risks ==============
|
||||
|
||||
export function listRisks(db: Db, companyId: string): Risk[] {
|
||||
const stmt = db.prepare(`
|
||||
SELECT id, company_id as companyId, risk, category, severity, likelihood, mitigation, status
|
||||
FROM risks
|
||||
WHERE company_id = ?
|
||||
ORDER BY severity DESC, likelihood DESC
|
||||
`);
|
||||
return stmt.all(companyId) as Risk[];
|
||||
}
|
||||
|
||||
export function addRisk(db: Db, companyId: string, risk: Omit<Risk, "id" | "companyId">): Risk {
|
||||
const id = `risk-${Date.now()}`;
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO risks (id, company_id, risk, category, severity, likelihood, mitigation, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(id, companyId, risk.risk, risk.category, risk.severity, risk.likelihood, risk.mitigation, risk.status);
|
||||
return { ...risk, id, companyId };
|
||||
}
|
||||
|
||||
// ============== Earnings ==============
|
||||
|
||||
export function getEarningsSchedule(db: Db, companyId: string): EarningsSchedule[] {
|
||||
const stmt = db.prepare(`
|
||||
SELECT id, company_id as companyId, quarter, expected_date as expectedDate, timing,
|
||||
actual_revenue as actualRevenue, expected_revenue as expectedRevenue,
|
||||
actual_eps as actualEps, expected_eps as expectedEps
|
||||
FROM earnings_schedules
|
||||
WHERE company_id = ?
|
||||
ORDER BY expected_date ASC
|
||||
`);
|
||||
return stmt.all(companyId) as EarningsSchedule[];
|
||||
}
|
||||
|
||||
// ============== Filings ==============
|
||||
|
||||
export function listFilings(db: Db, companyId: string, since?: string): Filing[] {
|
||||
let query = "SELECT id, company_id as companyId, form_type as formType, filed_date as filedDate, title, key_changes as keyChanges, reviewed FROM filings WHERE company_id = ?";
|
||||
const params: (string | undefined)[] = [companyId];
|
||||
|
||||
if (since) {
|
||||
query += " AND filed_date > ?";
|
||||
params.push(since);
|
||||
}
|
||||
|
||||
query += " ORDER BY filed_date DESC";
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
return (stmt.all(...params) as Array<Omit<Filing, "reviewed"> & { reviewed: number | boolean | null }>).map((filing) => ({
|
||||
...filing,
|
||||
reviewed: Boolean(filing.reviewed),
|
||||
}));
|
||||
}
|
||||
|
||||
// ============== Model ==============
|
||||
|
||||
export function getModel(db: Db, companyId: string, tab: string): { headers: string[]; rows: ModelRow[] } {
|
||||
const headerStmt = db.prepare(`
|
||||
SELECT label FROM model_headers
|
||||
WHERE model_id = (SELECT id FROM models WHERE company_id = ? AND tab = ?)
|
||||
ORDER BY position
|
||||
`);
|
||||
|
||||
const rowStmt = db.prepare(`
|
||||
SELECT label, kind, "values"
|
||||
FROM model_rows
|
||||
WHERE model_id = (SELECT id FROM models WHERE company_id = ? AND tab = ?)
|
||||
ORDER BY position
|
||||
`);
|
||||
|
||||
const headers = headerStmt.all(companyId, tab).map((h) => (h as { label: string }).label);
|
||||
const rows = (rowStmt.all(companyId, tab) as Array<Omit<ModelRow, "values"> & { values: string }>).map((row) => ({
|
||||
label: row.label,
|
||||
kind: row.kind,
|
||||
values: parseJsonWithSchema(row.values, z.array(z.string()), []),
|
||||
}));
|
||||
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
export function updateModelCell(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
tab: string,
|
||||
row: number,
|
||||
col: number,
|
||||
value: string
|
||||
): { ok: boolean; affectedCells: string[] } {
|
||||
if (row < 0 || col < 0) return { ok: false, affectedCells: [] };
|
||||
|
||||
// Get model ID
|
||||
const modelStmt = db.prepare("SELECT id FROM models WHERE company_id = ? AND tab = ?");
|
||||
const model = modelStmt.get(companyId, tab) as { id: string } | undefined;
|
||||
if (!model) return { ok: false, affectedCells: [] };
|
||||
|
||||
// Update the specific cell
|
||||
const rowStmt = db.prepare("SELECT \"values\" FROM model_rows WHERE model_id = ? AND position = ?");
|
||||
const rowRecord = rowStmt.get(model.id, row) as { values: string } | undefined;
|
||||
if (!rowRecord) return { ok: false, affectedCells: [] };
|
||||
|
||||
const values = parseJsonWithSchema(rowRecord.values, z.array(z.string()), []);
|
||||
if (col < 0 || col >= values.length) return { ok: false, affectedCells: [] };
|
||||
values[col] = value;
|
||||
|
||||
const updateStmt = db.prepare("UPDATE model_rows SET \"values\" = ? WHERE model_id = ? AND position = ?");
|
||||
updateStmt.run(JSON.stringify(values), model.id, row);
|
||||
|
||||
return { ok: true, affectedCells: [`${row}-${col}`] };
|
||||
}
|
||||
|
||||
// ============== Memo ==============
|
||||
|
||||
export function getMemo(db: Db, companyId: string) {
|
||||
const memoStmt = db.prepare("SELECT status FROM memos WHERE company_id = ?");
|
||||
const memo = memoStmt.get(companyId) as { status: "draft" | "review" | "final" } | undefined;
|
||||
|
||||
const sectionsStmt = db.prepare(`
|
||||
SELECT id, title, content, updated_at as updatedAt, primary_agent as primaryAgent
|
||||
FROM memo_sections
|
||||
WHERE company_id = ?
|
||||
ORDER BY id
|
||||
`);
|
||||
const sections = sectionsStmt.all(companyId) as MemoSection[];
|
||||
|
||||
const citationsStmt = db.prepare(`
|
||||
SELECT id, label, section_id as sectionId, type, title, reference, verification_status as verificationStatus, source_url as sourceUrl
|
||||
FROM memo_citations
|
||||
WHERE company_id = ?
|
||||
`);
|
||||
const citations = citationsStmt.all(companyId) as MemoCitation[];
|
||||
|
||||
const annotationsStmt = db.prepare(`
|
||||
SELECT id, section_id as sectionId, kind, selected_text as selectedText, comment, created_by as createdBy, created_at as createdAt, status
|
||||
FROM memo_annotations
|
||||
WHERE company_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
const annotations = annotationsStmt.all(companyId) as MemoAnnotation[];
|
||||
|
||||
const reviewsStmt = db.prepare(`
|
||||
SELECT section_id as sectionId, status, updated_at as updatedAt
|
||||
FROM memo_section_reviews
|
||||
WHERE company_id = ?
|
||||
`);
|
||||
const sectionReviews = reviewsStmt.all(companyId) as MemoSectionReview[];
|
||||
|
||||
return {
|
||||
status: memo?.status ?? "draft",
|
||||
sections,
|
||||
citations,
|
||||
annotations,
|
||||
sectionReviews,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateMemoSection(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
sectionId: string,
|
||||
updates: Partial<Pick<MemoSection, "title" | "content">>
|
||||
): MemoSection {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE memo_sections
|
||||
SET title = COALESCE(?, title),
|
||||
content = COALESCE(?, content),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ? AND company_id = ?
|
||||
RETURNING id, title, content, updated_at as updatedAt, primary_agent as primaryAgent
|
||||
`);
|
||||
|
||||
const result = stmt.get(updates.title, updates.content, sectionId, companyId) as MemoSection;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function addMemoAnnotation(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
annotation: Omit<MemoAnnotation, "id" | "companyId">
|
||||
): MemoAnnotation {
|
||||
const id = `annotation-${Date.now()}`;
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO memo_annotations (id, company_id, section_id, kind, selected_text, comment, created_by, created_at, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING *
|
||||
`);
|
||||
|
||||
return stmt.get(
|
||||
id,
|
||||
companyId,
|
||||
annotation.sectionId,
|
||||
annotation.kind,
|
||||
annotation.selectedText,
|
||||
annotation.comment ?? null,
|
||||
annotation.createdBy,
|
||||
annotation.createdAt,
|
||||
annotation.status
|
||||
) as MemoAnnotation;
|
||||
}
|
||||
|
||||
export function resolveMemoAnnotation(db: Db, companyId: string, annotationId: string): MemoAnnotation {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE memo_annotations
|
||||
SET status = 'resolved'
|
||||
WHERE id = ? AND company_id = ?
|
||||
RETURNING *
|
||||
`);
|
||||
|
||||
return stmt.get(annotationId, companyId) as MemoAnnotation;
|
||||
}
|
||||
|
||||
export function updateMemoSectionReview(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
sectionId: string,
|
||||
status: "pending" | "in_review" | "approved" | "changes_requested"
|
||||
): MemoSectionReview {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO memo_section_reviews (company_id, section_id, status, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(company_id, section_id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
updated_at = datetime('now')
|
||||
RETURNING *
|
||||
`);
|
||||
|
||||
return stmt.get(companyId, sectionId, status) as MemoSectionReview;
|
||||
}
|
||||
|
||||
// ============== Agents ==============
|
||||
|
||||
export function listAgents(db: Db, companyId?: string): Agent[] {
|
||||
const query = `
|
||||
SELECT
|
||||
a.id,
|
||||
a.name,
|
||||
COALESCE(r.status, 'idle') as status,
|
||||
COALESCE(r.progress, 0) as progress,
|
||||
r.action,
|
||||
a.pipeline
|
||||
FROM agents a
|
||||
LEFT JOIN agent_runs r ON a.id = r.agent_id
|
||||
AND r.id = (
|
||||
SELECT id FROM agent_runs
|
||||
WHERE agent_id = a.id
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
)
|
||||
ORDER BY a.name
|
||||
`;
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
return stmt.all() as Agent[];
|
||||
}
|
||||
|
||||
export function startAgent(db: Db, agentId: string, companyId: string): { runId: string } {
|
||||
const runId = `run-${Date.now()}`;
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO agent_runs (id, agent_id, company_id, status, started_at)
|
||||
VALUES (?, ?, ?, 'running', datetime('now'))
|
||||
`);
|
||||
stmt.run(runId, agentId, companyId);
|
||||
return { runId };
|
||||
}
|
||||
|
||||
export function pauseAgent(db: Db, agentId: string): { ok: boolean } {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE agent_runs
|
||||
SET status = 'paused'
|
||||
WHERE agent_id = ? AND status = 'running'
|
||||
`);
|
||||
const result = stmt.run(agentId);
|
||||
return { ok: result.changes > 0 };
|
||||
}
|
||||
|
||||
export function restartAgent(db: Db, agentId: string): { runId: string } {
|
||||
const runId = `run-${Date.now()}`;
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO agent_runs (id, agent_id, status, started_at)
|
||||
VALUES (?, ?, 'running', datetime('now'))
|
||||
`);
|
||||
stmt.run(runId, agentId);
|
||||
return { runId };
|
||||
}
|
||||
|
||||
// ============== Snapshots ==============
|
||||
|
||||
export function createSnapshot(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
data: string,
|
||||
options: Partial<Pick<Snapshot, "label" | "type" | "changeCount">> = {}
|
||||
): Snapshot {
|
||||
const id = `snapshot-${Date.now()}`;
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO snapshots (id, company_id, timestamp, label, type, change_count, data)
|
||||
VALUES (?, ?, datetime('now'), ?, ?, ?, ?)
|
||||
RETURNING *
|
||||
`);
|
||||
|
||||
return stmt.get(
|
||||
id,
|
||||
companyId,
|
||||
options.label ?? null,
|
||||
options.type ?? "auto",
|
||||
options.changeCount ?? 0,
|
||||
data
|
||||
) as Snapshot;
|
||||
}
|
||||
|
||||
export function listSnapshots(db: Db, companyId: string): Snapshot[] {
|
||||
const stmt = db.prepare(`
|
||||
SELECT id, company_id as companyId, timestamp, label, type, change_count as changeCount
|
||||
FROM snapshots
|
||||
WHERE company_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 100
|
||||
`);
|
||||
return stmt.all(companyId) as Snapshot[];
|
||||
}
|
||||
|
||||
export function getSnapshot(db: Db, snapshotId: string): Snapshot | null {
|
||||
const stmt = db.prepare("SELECT * FROM snapshots WHERE id = ?");
|
||||
return stmt.get(snapshotId) as Snapshot | null;
|
||||
}
|
||||
|
||||
export function deleteSnapshot(db: Db, snapshotId: string): boolean {
|
||||
const stmt = db.prepare("DELETE FROM snapshots WHERE id = ?");
|
||||
const result = stmt.run(snapshotId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// ============== Exports ==============
|
||||
|
||||
export function listExports(db: Db, companyId?: string): ExportRecord[] {
|
||||
let query = "SELECT id, type, title, company_id as companyId, format, file_size as fileSize, status, created_at as createdAt, download_url as downloadUrl FROM export_records";
|
||||
const params: (string | undefined)[] = [];
|
||||
|
||||
if (companyId) {
|
||||
query += " WHERE company_id = ?";
|
||||
params.push(companyId);
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC";
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
return stmt.all(...params) as ExportRecord[];
|
||||
}
|
||||
|
||||
export function createExport(
|
||||
db: Db,
|
||||
type: string,
|
||||
title: string,
|
||||
companyId: string,
|
||||
format: string
|
||||
): { exportId: string } {
|
||||
const id = `export-${Date.now()}`;
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO export_records (id, type, title, company_id, format, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'processing')
|
||||
`);
|
||||
stmt.run(id, type, title, companyId, format);
|
||||
return { exportId: id };
|
||||
}
|
||||
|
||||
export function updateExportStatus(
|
||||
db: Db,
|
||||
exportId: string,
|
||||
status: "processing" | "complete" | "failed",
|
||||
downloadUrl?: string
|
||||
): void {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE export_records
|
||||
SET status = ?, download_url = COALESCE(?, download_url)
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(status, downloadUrl ?? null, exportId);
|
||||
}
|
||||
|
||||
// ============== Settings ==============
|
||||
|
||||
const DEFAULT_CLIENT_SETTINGS: ClientSettings = {
|
||||
theme: "light",
|
||||
density: "comfortable",
|
||||
sidebarWidth: 240,
|
||||
navCollapsed: {},
|
||||
keybindings: {
|
||||
"memo.save": "Cmd+S",
|
||||
"memo.newSection": "Cmd+Shift+N",
|
||||
"model.addCell": "Cmd+Enter",
|
||||
"navigation.workspace": "Cmd+1",
|
||||
"navigation.model": "Cmd+2",
|
||||
"navigation.memo": "Cmd+3",
|
||||
"navigation.agents": "Cmd+4",
|
||||
"navigation.home": "Cmd+0",
|
||||
},
|
||||
};
|
||||
|
||||
export function getClientSettings(db: Db): ClientSettings {
|
||||
const stmt = db.prepare("SELECT key, value FROM client_settings");
|
||||
const rows = stmt.all() as Array<{ key: string; value: string }>;
|
||||
|
||||
const settings = { ...DEFAULT_CLIENT_SETTINGS };
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.value) as unknown;
|
||||
if (row.key === "keybindings") {
|
||||
const keybindings = z.record(z.string()).safeParse(parsed);
|
||||
if (keybindings.success) settings.keybindings = { ...settings.keybindings, ...keybindings.data };
|
||||
} else if (row.key === "navCollapsed") {
|
||||
const navCollapsed = z.record(z.boolean()).safeParse(parsed);
|
||||
if (navCollapsed.success) settings.navCollapsed = { ...settings.navCollapsed, ...navCollapsed.data };
|
||||
} else {
|
||||
const candidate = { ...settings, [row.key]: parsed };
|
||||
const validated = ClientSettingsSchema.safeParse(candidate);
|
||||
if (validated.success) {
|
||||
Object.assign(settings, validated.data);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid values
|
||||
}
|
||||
}
|
||||
|
||||
return ClientSettingsSchema.parse(settings);
|
||||
}
|
||||
|
||||
export function updateClientSetting(db: Db, key: string, value: unknown): void {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO client_settings (key, value, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = datetime('now')
|
||||
`);
|
||||
stmt.run(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function updateClientSettings(db: Db, settings: Partial<ClientSettings>): void {
|
||||
if (settings.theme !== undefined) {
|
||||
updateClientSetting(db, "theme", settings.theme);
|
||||
}
|
||||
if (settings.density !== undefined) {
|
||||
updateClientSetting(db, "density", settings.density);
|
||||
}
|
||||
if (settings.sidebarWidth !== undefined) {
|
||||
updateClientSetting(db, "sidebarWidth", settings.sidebarWidth);
|
||||
}
|
||||
if (settings.navCollapsed !== undefined) {
|
||||
updateClientSetting(db, "navCollapsed", settings.navCollapsed);
|
||||
}
|
||||
if (settings.keybindings !== undefined) {
|
||||
updateClientSetting(db, "keybindings", settings.keybindings);
|
||||
}
|
||||
}
|
||||
|
||||
export function getServerSettings(db: Db): ServerSettings {
|
||||
const stmt = db.prepare("SELECT agent_id as agentId, config FROM agent_configs");
|
||||
const rows = stmt.all() as Array<{ agentId: string; config: string }>;
|
||||
|
||||
const agentConfigs: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
try {
|
||||
agentConfigs[row.agentId] = JSON.parse(row.config) as unknown;
|
||||
} catch {
|
||||
agentConfigs[row.agentId] = {};
|
||||
}
|
||||
}
|
||||
|
||||
return ServerSettingsSchema.parse({
|
||||
agentConfigs,
|
||||
dataSources: {
|
||||
sec_filings: true,
|
||||
transcripts: true,
|
||||
market_data: false,
|
||||
analyst_reports: false,
|
||||
press_releases: true,
|
||||
},
|
||||
exportPipelines: {},
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAgentConfig(db: Db, agentId: string, config: Record<string, unknown>): void {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO agent_configs (agent_id, config, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(agent_id) DO UPDATE SET
|
||||
config = excluded.config,
|
||||
updated_at = datetime('now')
|
||||
`);
|
||||
stmt.run(agentId, JSON.stringify(config));
|
||||
}
|
||||
|
||||
export function updateServerSettings(
|
||||
db: Db,
|
||||
settings: Partial<ServerSettings>
|
||||
): void {
|
||||
if (settings.agentConfigs !== undefined) {
|
||||
for (const [agentId, config] of Object.entries(settings.agentConfigs)) {
|
||||
updateAgentConfig(db, agentId, config as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
// Data sources and export pipelines are stored separately
|
||||
// For now, they're hardcoded in getServerSettings
|
||||
// TODO: Add separate tables for data_sources and export_pipelines
|
||||
}
|
||||
572
packages/shared/src/db/rpcHandler.ts
Normal file
572
packages/shared/src/db/rpcHandler.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Real RPC handler backed by SQLite database
|
||||
* Replaces mockRpc.ts with persistent data storage
|
||||
*/
|
||||
|
||||
import { searchStocks, fetchQuote, getCompanyProfile } from "../data/market.js";
|
||||
import { fetchFilings as fetchSECFilings, getCompanyInfo as getSECCompanyInfo } from "../data/sec.js";
|
||||
import { getEarningsDate } from "../data/earnings.js";
|
||||
import { executeSourceVerification, executeModelQA, executeRedTeam, runAllValidations } from "../agents/validationAgents.js";
|
||||
|
||||
import type { Db } from "./database.js";
|
||||
import type {
|
||||
RpcMethod,
|
||||
RpcRequestMap,
|
||||
RpcResponseMap,
|
||||
RpcResult,
|
||||
ModelRow,
|
||||
WorkspaceSection,
|
||||
ClientSettings,
|
||||
ServerSettings,
|
||||
} from "@mosaiciq/contracts/rpc";
|
||||
import {
|
||||
getPortfolio,
|
||||
addHolding as dbAddHolding,
|
||||
removeHolding as dbRemoveHolding,
|
||||
setActiveCompany as dbSetActiveCompany,
|
||||
getCompany,
|
||||
getCompanyByTicker,
|
||||
searchCompanies as dbSearchCompanies,
|
||||
upsertCompany,
|
||||
getWorkspaceSection as dbGetWorkspaceSection,
|
||||
listCatalysts,
|
||||
listAlerts,
|
||||
listRisks,
|
||||
addRisk as dbAddRisk,
|
||||
getEarningsSchedule,
|
||||
listFilings,
|
||||
getModel,
|
||||
updateModelCell as dbUpdateModelCell,
|
||||
getMemo,
|
||||
updateMemoSection as dbUpdateMemoSection,
|
||||
addMemoAnnotation as dbAddMemoAnnotation,
|
||||
resolveMemoAnnotation as dbResolveMemoAnnotation,
|
||||
updateMemoSectionReview as dbUpdateMemoSectionReview,
|
||||
listAgents,
|
||||
startAgent as dbStartAgent,
|
||||
pauseAgent as dbPauseAgent,
|
||||
restartAgent as dbRestartAgent,
|
||||
listExports,
|
||||
createExport as dbCreateExport,
|
||||
updateExportStatus,
|
||||
getClientSettings,
|
||||
updateClientSettings,
|
||||
getServerSettings,
|
||||
updateServerSettings as dbUpdateServerSettings,
|
||||
updateAgentConfig,
|
||||
} from "./queries.js";
|
||||
import { DEFAULT_PORTFOLIO_ID } from "./schema.js";
|
||||
|
||||
export function createRpcHandler(db: Db) {
|
||||
return async function handleRpc<T extends RpcMethod>(
|
||||
method: T,
|
||||
payload: RpcRequestMap[T]
|
||||
): Promise<RpcResult<T>> {
|
||||
try {
|
||||
switch (method) {
|
||||
case "portfolio.get": {
|
||||
const portfolio = getPortfolio(db);
|
||||
if (!portfolio) {
|
||||
return fail("NOT_FOUND", "Portfolio not found.") as RpcResult<T>;
|
||||
}
|
||||
return ok("portfolio.get", portfolio) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "portfolio.addHolding": {
|
||||
const { ticker } = payload as RpcRequestMap["portfolio.addHolding"];
|
||||
const company = getCompany(db, ticker.toUpperCase());
|
||||
if (!company) {
|
||||
return fail("NOT_FOUND", `Company with ticker "${ticker}" not found.`) as RpcResult<T>;
|
||||
}
|
||||
const holding = {
|
||||
ticker: company.ticker,
|
||||
name: company.name,
|
||||
price: company.price,
|
||||
changePct: company.changePct,
|
||||
weight: 5,
|
||||
};
|
||||
dbAddHolding(db, DEFAULT_PORTFOLIO_ID, holding);
|
||||
return ok("portfolio.addHolding", { holding }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "portfolio.removeHolding": {
|
||||
const { ticker } = payload as RpcRequestMap["portfolio.removeHolding"];
|
||||
dbRemoveHolding(db, DEFAULT_PORTFOLIO_ID, ticker.toUpperCase());
|
||||
return ok("portfolio.removeHolding", { ok: true }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "company.get": {
|
||||
const { companyId } = payload as RpcRequestMap["company.get"];
|
||||
let company = getCompany(db, companyId);
|
||||
|
||||
if (!company) {
|
||||
// Try to fetch from external API
|
||||
try {
|
||||
const quote = await fetchQuote(companyId);
|
||||
const profile = await getCompanyProfile(companyId);
|
||||
|
||||
if (quote || profile) {
|
||||
upsertCompany(db, {
|
||||
ticker: companyId.toUpperCase(),
|
||||
name: profile?.name || companyId.toUpperCase(),
|
||||
sector: profile?.sector || "Unknown",
|
||||
subIndustry: profile?.industry,
|
||||
price: quote?.price || 0,
|
||||
changePct: quote?.changePercent || 0,
|
||||
thesis: "",
|
||||
});
|
||||
|
||||
company = getCompany(db, companyId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[RPC] Error fetching company:", error);
|
||||
}
|
||||
|
||||
if (!company) {
|
||||
return fail("NOT_FOUND", `Company "${companyId}" was not found.`) as RpcResult<T>;
|
||||
}
|
||||
}
|
||||
|
||||
// Update with live price data
|
||||
try {
|
||||
const quote = await fetchQuote(company.ticker);
|
||||
if (quote) {
|
||||
company = {
|
||||
...company,
|
||||
price: quote.price,
|
||||
changePct: quote.changePercent,
|
||||
};
|
||||
|
||||
// Update in database
|
||||
db.prepare(`
|
||||
UPDATE companies
|
||||
SET price = ?, change_pct = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(quote.price, quote.changePercent, companyId);
|
||||
}
|
||||
} catch (error) {
|
||||
// Use cached price if fetch fails
|
||||
}
|
||||
|
||||
return ok("company.get", { company }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "company.search": {
|
||||
const { query } = payload as RpcRequestMap["company.search"];
|
||||
|
||||
// First search local database
|
||||
const localResults = dbSearchCompanies(db, query);
|
||||
|
||||
// Also search Yahoo Finance for additional results
|
||||
try {
|
||||
const apiResults = await searchStocks(query);
|
||||
|
||||
// Store new companies in database
|
||||
for (const result of apiResults) {
|
||||
const existing = getCompanyByTicker(db, result.ticker);
|
||||
if (!existing) {
|
||||
// Fetch additional details
|
||||
const profile = await getCompanyProfile(result.ticker);
|
||||
|
||||
upsertCompany(db, {
|
||||
ticker: result.ticker,
|
||||
name: profile?.name || result.name,
|
||||
sector: profile?.sector || "Unknown",
|
||||
subIndustry: profile?.industry,
|
||||
price: 0,
|
||||
changePct: 0,
|
||||
thesis: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Combine results
|
||||
const apiTickerSet = new Set(apiResults.map((r) => r.ticker));
|
||||
const combinedResults = [
|
||||
...localResults.filter((r) => !apiTickerSet.has(r.ticker)),
|
||||
...apiResults.map((r) => ({
|
||||
ticker: r.ticker,
|
||||
name: r.name,
|
||||
sector: "Unknown",
|
||||
})),
|
||||
];
|
||||
|
||||
return ok("company.search", { results: combinedResults.slice(0, 20) }) as RpcResult<T>;
|
||||
} catch (error) {
|
||||
// Fallback to local results only
|
||||
console.error("[RPC] Error in company.search API:", error);
|
||||
return ok("company.search", { results: localResults }) as RpcResult<T>;
|
||||
}
|
||||
}
|
||||
|
||||
case "company.setActive": {
|
||||
const { companyId } = payload as RpcRequestMap["company.setActive"];
|
||||
dbSetActiveCompany(db, DEFAULT_PORTFOLIO_ID, companyId);
|
||||
return ok("company.setActive", { ok: true }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "workspace.getSection": {
|
||||
const { companyId, section } = payload as RpcRequestMap["workspace.getSection"];
|
||||
const content = dbGetWorkspaceSection(db, companyId, section);
|
||||
if (!content) {
|
||||
// Create a default section if it doesn't exist
|
||||
const newSection: WorkspaceSection = {
|
||||
id: `ws-${Date.now()}`,
|
||||
title: section,
|
||||
content: "",
|
||||
validationState: "unverified",
|
||||
sourceAgent: undefined,
|
||||
};
|
||||
return ok("workspace.getSection", {
|
||||
content: newSection,
|
||||
validationState: "unverified",
|
||||
}) as RpcResult<T>;
|
||||
}
|
||||
return ok("workspace.getSection", {
|
||||
content,
|
||||
validationState: content.validationState,
|
||||
}) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "workspace.listSources": {
|
||||
const { companyId } = payload as RpcRequestMap["workspace.listSources"];
|
||||
const sources = [
|
||||
{ type: "SEC Filing", title: "10-K FY2024", metadata: "Filed Oct 2024" },
|
||||
{ type: "Earnings Transcript", title: "Q2 FY25 Call", metadata: "Mar 2025" },
|
||||
];
|
||||
return ok("workspace.listSources", { sources }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "catalyst.list": {
|
||||
const { companyId } = payload as RpcRequestMap["catalyst.list"];
|
||||
const catalysts = listCatalysts(db, companyId);
|
||||
return ok("catalyst.list", { catalysts }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "alert.list": {
|
||||
const params = payload as RpcRequestMap["alert.list"];
|
||||
const alerts = listAlerts(db, params.companyId, params.since);
|
||||
return ok("alert.list", { alerts }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "risk.list": {
|
||||
const { companyId } = payload as RpcRequestMap["risk.list"];
|
||||
const risks = listRisks(db, companyId);
|
||||
return ok("risk.list", { risks }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "risk.add": {
|
||||
const { companyId, risk } = payload as RpcRequestMap["risk.add"];
|
||||
const newRisk = dbAddRisk(db, companyId, risk);
|
||||
return ok("risk.add", { risk: newRisk }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "earnings.getSchedule": {
|
||||
const { companyId } = payload as RpcRequestMap["earnings.getSchedule"];
|
||||
let schedule = getEarningsSchedule(db, companyId);
|
||||
|
||||
// If no schedule in database, fetch from API
|
||||
if (schedule.length === 0) {
|
||||
try {
|
||||
const earningsDate = await getEarningsDate(companyId);
|
||||
if (earningsDate) {
|
||||
const quarter = `Q${Math.floor(earningsDate.getMonth() / 3) + 1} ${earningsDate.getFullYear()}`;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO earnings_schedules (id, company_id, quarter, expected_date)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(
|
||||
`earnings-${companyId}-${Date.now()}`,
|
||||
companyId,
|
||||
quarter,
|
||||
earningsDate.toISOString()
|
||||
);
|
||||
|
||||
schedule = getEarningsSchedule(db, companyId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[RPC] Error fetching earnings date:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return ok("earnings.getSchedule", { schedule }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "filing.list": {
|
||||
const { companyId, since } = payload as RpcRequestMap["filing.list"];
|
||||
let filings = listFilings(db, companyId, since);
|
||||
|
||||
// If no filings in database, fetch from SEC
|
||||
if (filings.length === 0) {
|
||||
try {
|
||||
const secFilings = await fetchSECFilings(companyId, { limit: 20 });
|
||||
|
||||
for (const filing of secFilings) {
|
||||
db.prepare(`
|
||||
INSERT INTO filings (id, company_id, form_type, filed_date, title)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
`${companyId}-${filing.formType}-${filing.filedDate}`,
|
||||
companyId,
|
||||
filing.formType,
|
||||
filing.filedDate,
|
||||
filing.title
|
||||
);
|
||||
}
|
||||
|
||||
filings = secFilings.map((f) => ({
|
||||
id: `${companyId}-${f.formType}-${f.filedDate}`,
|
||||
companyId,
|
||||
formType: f.formType,
|
||||
filedDate: f.filedDate,
|
||||
title: f.title,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("[RPC] Error fetching SEC filings:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return ok("filing.list", { filings }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "model.get": {
|
||||
const { companyId, tab } = payload as RpcRequestMap["model.get"];
|
||||
const model = getModel(db, companyId, tab);
|
||||
return ok("model.get", model) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "model.updateCell": {
|
||||
const { companyId, tab, row, col, value } = payload as RpcRequestMap[
|
||||
"model.updateCell"
|
||||
];
|
||||
const result = dbUpdateModelCell(db, companyId, tab, row, col, value);
|
||||
return ok("model.updateCell", result) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "model.runScenario": {
|
||||
const { companyId, scenario } = payload as RpcRequestMap["model.runScenario"];
|
||||
// For now, just return the current model
|
||||
// TODO: Implement scenario logic
|
||||
const model = getModel(db, companyId, "income");
|
||||
return ok("model.runScenario", model) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "memo.get": {
|
||||
const { companyId } = payload as RpcRequestMap["memo.get"];
|
||||
const memo = getMemo(db, companyId);
|
||||
return ok("memo.get", memo) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "memo.updateSection": {
|
||||
const { companyId, sectionId, title, content } = payload as RpcRequestMap[
|
||||
"memo.updateSection"
|
||||
];
|
||||
if (content.trim().length === 0) {
|
||||
return fail("VALIDATION_ERROR", "Memo section content cannot be empty.") as RpcResult<T>;
|
||||
}
|
||||
if (title !== undefined && title.trim().length === 0) {
|
||||
return fail("VALIDATION_ERROR", "Memo section title cannot be empty.") as RpcResult<T>;
|
||||
}
|
||||
|
||||
const section = dbUpdateMemoSection(db, companyId, sectionId, { title, content });
|
||||
const savedAt = new Date().toISOString();
|
||||
|
||||
return ok("memo.updateSection", {
|
||||
section,
|
||||
status: "draft",
|
||||
savedAt,
|
||||
}) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "memo.addAnnotation": {
|
||||
const { companyId, sectionId, kind, selectedText, comment } = payload as RpcRequestMap[
|
||||
"memo.addAnnotation"
|
||||
];
|
||||
if (selectedText.trim().length === 0) {
|
||||
return fail("VALIDATION_ERROR", "Annotation selected text cannot be empty.") as RpcResult<T>;
|
||||
}
|
||||
if (kind === "comment" && (!comment || comment.trim().length === 0)) {
|
||||
return fail("VALIDATION_ERROR", "Comment annotation requires comment text.") as RpcResult<T>;
|
||||
}
|
||||
|
||||
const annotation = dbAddMemoAnnotation(db, companyId, {
|
||||
sectionId,
|
||||
kind,
|
||||
selectedText: selectedText.trim(),
|
||||
comment,
|
||||
createdBy: "JD",
|
||||
createdAt: new Date().toISOString(),
|
||||
status: "open",
|
||||
});
|
||||
|
||||
return ok("memo.addAnnotation", { annotation }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "memo.resolveAnnotation": {
|
||||
const { companyId, annotationId } = payload as RpcRequestMap["memo.resolveAnnotation"];
|
||||
const annotation = dbResolveMemoAnnotation(db, companyId, annotationId);
|
||||
if (!annotation) {
|
||||
return fail("NOT_FOUND", `Annotation "${annotationId}" not found.`) as RpcResult<T>;
|
||||
}
|
||||
return ok("memo.resolveAnnotation", { annotation }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "memo.updateSectionReview": {
|
||||
const { companyId, sectionId, status } = payload as RpcRequestMap[
|
||||
"memo.updateSectionReview"
|
||||
];
|
||||
const review = dbUpdateMemoSectionReview(db, companyId, sectionId, status);
|
||||
return ok("memo.updateSectionReview", { review }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "memo.acceptEdit":
|
||||
return ok("memo.acceptEdit", { ok: true }) as RpcResult<T>;
|
||||
|
||||
case "memo.rejectEdit":
|
||||
return ok("memo.rejectEdit", { ok: true }) as RpcResult<T>;
|
||||
|
||||
case "agent.list": {
|
||||
const params = payload as RpcRequestMap["agent.list"];
|
||||
const agents = listAgents(db, params.companyId);
|
||||
return ok("agent.list", { agents }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "agent.start": {
|
||||
const { agentId, companyId } = payload as RpcRequestMap["agent.start"];
|
||||
const { runId } = dbStartAgent(db, agentId, companyId);
|
||||
return ok("agent.start", { runId }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "agent.pause": {
|
||||
const { agentId } = payload as RpcRequestMap["agent.pause"];
|
||||
const result = dbPauseAgent(db, agentId);
|
||||
return ok("agent.pause", result) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "agent.restart": {
|
||||
const { agentId } = payload as RpcRequestMap["agent.restart"];
|
||||
const { runId } = dbRestartAgent(db, agentId);
|
||||
return ok("agent.restart", { runId }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "agent.chat":
|
||||
return ok("agent.chat", {
|
||||
response: "I've analyzed the data. Key findings: revenue growth remains on track with 5.6% YoY.",
|
||||
}) as RpcResult<T>;
|
||||
|
||||
case "agent.configure": {
|
||||
const { agentId, config } = payload as RpcRequestMap["agent.configure"];
|
||||
updateAgentConfig(db, agentId, config);
|
||||
return ok("agent.configure", { ok: true }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "agent.getTrace":
|
||||
return ok("agent.getTrace", {
|
||||
steps: [
|
||||
{ step: 1, label: "Load filings", detail: "Loaded 10-K and 3 quarterly reports" },
|
||||
{ step: 2, label: "Extract segments", detail: "Parsed 5 revenue segments" },
|
||||
{ step: 3, label: "Build model", detail: "Constructed revenue build with growth rates" },
|
||||
],
|
||||
}) as RpcResult<T>;
|
||||
|
||||
case "agent.runPipeline": {
|
||||
const { companyId, pipeline } = payload as RpcRequestMap["agent.runPipeline"];
|
||||
// Start all agents in the pipeline
|
||||
const runIds: string[] = [];
|
||||
// TODO: Get agents for pipeline and start them
|
||||
return ok("agent.runPipeline", { runIds }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "validation.run": {
|
||||
const { companyId, agentType } = payload as RpcRequestMap["validation.run"];
|
||||
try {
|
||||
if (!agentType || agentType === "all") {
|
||||
const results = await runAllValidations(db, companyId);
|
||||
return ok("validation.run", {
|
||||
sourceVerification: results.sourceVerification,
|
||||
modelQA: results.modelQA,
|
||||
redTeam: results.redTeam,
|
||||
}) as RpcResult<T>;
|
||||
} else if (agentType === "sv") {
|
||||
const result = await executeSourceVerification(db, companyId);
|
||||
return ok("validation.run", { sourceVerification: result }) as RpcResult<T>;
|
||||
} else if (agentType === "qa") {
|
||||
const result = await executeModelQA(db, companyId);
|
||||
return ok("validation.run", { modelQA: result }) as RpcResult<T>;
|
||||
} else if (agentType === "rt") {
|
||||
const result = await executeRedTeam(db, companyId);
|
||||
return ok("validation.run", { redTeam: result }) as RpcResult<T>;
|
||||
}
|
||||
return fail("VALIDATION_ERROR", `Unknown validation agent type: ${agentType}`) as RpcResult<T>;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return fail("INTERNAL_ERROR", `Validation failed: ${errorMsg}`, error) as RpcResult<T>;
|
||||
}
|
||||
}
|
||||
|
||||
case "validation.getStatus": {
|
||||
const { companyId, sectionId } = payload as RpcRequestMap["validation.getStatus"];
|
||||
// For now, return unverified - in production this would check validation records
|
||||
return ok("validation.getStatus", {
|
||||
validationState: "unverified",
|
||||
lastValidated: undefined,
|
||||
}) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "export.list": {
|
||||
const params = payload as RpcRequestMap["export.list"];
|
||||
const exports = listExports(db, params.companyId);
|
||||
return ok("export.list", { exports }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "export.create": {
|
||||
const { type, companyId, options } = payload as RpcRequestMap["export.create"];
|
||||
const format = options?.format as string ?? "pdf";
|
||||
const { exportId } = dbCreateExport(db, type, `Export ${type}`, companyId, format);
|
||||
return ok("export.create", { exportId }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "export.download":
|
||||
return ok("export.download", { data: new ArrayBuffer(0) }) as RpcResult<T>;
|
||||
|
||||
case "settings.get": {
|
||||
const { scope } = payload as RpcRequestMap["settings.get"];
|
||||
if (scope === "client") {
|
||||
const settings = getClientSettings(db);
|
||||
return ok("settings.get", { settings }) as RpcResult<T>;
|
||||
}
|
||||
const settings = getServerSettings(db);
|
||||
return ok("settings.get", { settings }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
case "settings.update": {
|
||||
const { scope, changes } = payload as RpcRequestMap["settings.update"];
|
||||
if (scope === "client") {
|
||||
updateClientSettings(db, changes as Partial<ClientSettings>);
|
||||
} else {
|
||||
dbUpdateServerSettings(db, changes as Partial<ServerSettings>);
|
||||
}
|
||||
return ok("settings.update", { ok: true }) as RpcResult<T>;
|
||||
}
|
||||
|
||||
default:
|
||||
return fail("NOT_FOUND", `Unknown RPC method: ${String(method)}`) as RpcResult<T>;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[RPC] Error handling request:", error);
|
||||
return fail("INTERNAL_ERROR", "Unhandled RPC failure.", error) as RpcResult<T>;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function ok<T extends RpcMethod>(_: T, data: RpcResponseMap[T]): RpcResult<T> {
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
function fail<T extends RpcMethod>(
|
||||
code: "NOT_FOUND" | "VALIDATION_ERROR" | "INTERNAL_ERROR" | "AGENT_FAILED" | "CONFLICT" | "RATE_LIMITED",
|
||||
message: string,
|
||||
detail?: unknown
|
||||
): RpcResult<T> {
|
||||
return { ok: false, error: { code, message, detail } };
|
||||
}
|
||||
328
packages/shared/src/db/schema.ts
Normal file
328
packages/shared/src/db/schema.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* SQLite schema for MosaicIQ v3
|
||||
* All data is stored locally with type-safe accessors
|
||||
*/
|
||||
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
export const SQL_SCHEMA = `
|
||||
-- Database version tracking
|
||||
CREATE TABLE IF NOT EXISTS _meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Companies
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id TEXT PRIMARY KEY,
|
||||
ticker TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
sector TEXT NOT NULL,
|
||||
sub_industry TEXT,
|
||||
price REAL NOT NULL DEFAULT 0,
|
||||
change_pct REAL NOT NULL DEFAULT 0,
|
||||
thesis TEXT NOT NULL DEFAULT '',
|
||||
founded TEXT,
|
||||
headquarters TEXT,
|
||||
employees INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_companies_ticker ON companies(ticker);
|
||||
CREATE INDEX IF NOT EXISTS idx_companies_sector ON companies(sector);
|
||||
|
||||
-- Portfolio
|
||||
CREATE TABLE IF NOT EXISTS portfolios (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
active_company_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (active_company_id) REFERENCES companies(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS holdings (
|
||||
portfolio_id TEXT NOT NULL,
|
||||
ticker TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
change_pct REAL NOT NULL,
|
||||
weight REAL NOT NULL,
|
||||
PRIMARY KEY (portfolio_id, ticker),
|
||||
FOREIGN KEY (portfolio_id) REFERENCES portfolios(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Workspace sections
|
||||
CREATE TABLE IF NOT EXISTS workspace_sections (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
validation_state TEXT NOT NULL DEFAULT 'unverified',
|
||||
source_agent TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_company ON workspace_sections(company_id);
|
||||
|
||||
-- Catalysts
|
||||
CREATE TABLE IF NOT EXISTS catalysts (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
impact TEXT NOT NULL,
|
||||
thesis_relevance TEXT NOT NULL,
|
||||
source TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_catalysts_company ON catalysts(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_catalysts_date ON catalysts(date);
|
||||
|
||||
-- Alerts
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
type TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
thesis_impact TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'new',
|
||||
target_section TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_company ON alerts(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_status ON alerts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_timestamp ON alerts(timestamp);
|
||||
|
||||
-- Risks
|
||||
CREATE TABLE IF NOT EXISTS risks (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
risk TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
likelihood TEXT NOT NULL,
|
||||
mitigation TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_risks_company ON risks(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_risks_status ON risks(status);
|
||||
|
||||
-- Earnings schedules
|
||||
CREATE TABLE IF NOT EXISTS earnings_schedules (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
quarter TEXT NOT NULL,
|
||||
expected_date TEXT NOT NULL,
|
||||
timing TEXT,
|
||||
actual_revenue TEXT,
|
||||
expected_revenue TEXT,
|
||||
actual_eps TEXT,
|
||||
expected_eps TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_earnings_company ON earnings_schedules(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_earnings_date ON earnings_schedules(expected_date);
|
||||
|
||||
-- Filings
|
||||
CREATE TABLE IF NOT EXISTS filings (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
form_type TEXT NOT NULL,
|
||||
filed_date TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
key_changes TEXT,
|
||||
reviewed INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_filings_company ON filings(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_filings_date ON filings(filed_date);
|
||||
|
||||
-- Model data
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
tab TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
||||
UNIQUE(company_id, tab)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_headers (
|
||||
model_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
PRIMARY KEY (model_id, position),
|
||||
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_rows (
|
||||
model_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
"values" TEXT NOT NULL,
|
||||
PRIMARY KEY (model_id, position),
|
||||
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_model_company_tab ON models(company_id, tab);
|
||||
|
||||
-- Memos
|
||||
CREATE TABLE IF NOT EXISTS memos (
|
||||
company_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memo_sections (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
primary_agent TEXT,
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memo_sections_company ON memo_sections(company_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memo_citations (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
section_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
reference TEXT NOT NULL,
|
||||
verification_status TEXT NOT NULL DEFAULT 'unverified',
|
||||
source_url TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memo_citations_company ON memo_citations(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_memo_citations_section ON memo_citations(section_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memo_annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
section_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
selected_text TEXT NOT NULL,
|
||||
comment TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memo_annotations_company ON memo_annotations(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_memo_annotations_section ON memo_annotations(section_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memo_section_reviews (
|
||||
company_id TEXT NOT NULL,
|
||||
section_id TEXT NOT NULL PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memo_reviews_company ON memo_section_reviews(company_id);
|
||||
|
||||
-- Snapshots
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
label TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'auto',
|
||||
change_count INTEGER NOT NULL DEFAULT 0,
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_company ON snapshots(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_timestamp ON snapshots(timestamp);
|
||||
|
||||
-- Agent definitions
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
pipeline TEXT
|
||||
);
|
||||
|
||||
-- Agent runs
|
||||
CREATE TABLE IF NOT EXISTS agent_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
company_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
action TEXT,
|
||||
pipeline TEXT,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_agent ON agent_runs(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_company ON agent_runs(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_status ON agent_runs(status);
|
||||
|
||||
-- Agent configurations (server settings)
|
||||
CREATE TABLE IF NOT EXISTS agent_configs (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
config TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Client settings (UI preferences that persist across sessions)
|
||||
CREATE TABLE IF NOT EXISTS client_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Export records
|
||||
CREATE TABLE IF NOT EXISTS export_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
company_id TEXT,
|
||||
format TEXT NOT NULL,
|
||||
file_size TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'processing',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
download_url TEXT,
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_exports_company ON export_records(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_exports_status ON export_records(status);
|
||||
`;
|
||||
|
||||
export const DEFAULT_PORTFOLIO_ID = "default";
|
||||
271
packages/shared/src/db/seed.ts
Normal file
271
packages/shared/src/db/seed.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Seed database with demo data for MosaicIQ
|
||||
*/
|
||||
|
||||
import type { Db } from "./database.js";
|
||||
import type {
|
||||
Catalyst,
|
||||
Alert,
|
||||
Risk,
|
||||
EarningsSchedule,
|
||||
Filing,
|
||||
ExportRecord,
|
||||
Agent,
|
||||
Company,
|
||||
} from "@mosaiciq/contracts/rpc";
|
||||
import {
|
||||
activeCompanyId,
|
||||
holdings,
|
||||
companies,
|
||||
agents,
|
||||
modelHeaders,
|
||||
modelRows,
|
||||
memoSections,
|
||||
memoCitations,
|
||||
memoAnnotations,
|
||||
memoSectionReviews,
|
||||
} from "../demoData.js";
|
||||
import { demoAlerts, demoCatalysts, demoEarnings, demoExports, demoFilings, demoRisks } from "../extraData.js";
|
||||
|
||||
export function seedDatabase(db: Db): void {
|
||||
console.log("[DB] Seeding database with demo data...");
|
||||
|
||||
// Insert companies
|
||||
const companyStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO companies (id, ticker, name, sector, sub_industry, price, change_pct, thesis, founded, headquarters, employees)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const company of Object.values(companies) as Company[]) {
|
||||
companyStmt.run(
|
||||
company.id,
|
||||
company.ticker,
|
||||
company.name,
|
||||
company.sector,
|
||||
company.subIndustry ?? null,
|
||||
company.price,
|
||||
company.changePct,
|
||||
company.thesis,
|
||||
company.founded ?? null,
|
||||
company.headquarters ?? null,
|
||||
company.employees ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// Set active company and add holdings
|
||||
const portfolioStmt = db.prepare("UPDATE portfolios SET active_company_id = ? WHERE id = ?");
|
||||
portfolioStmt.run(activeCompanyId, "default");
|
||||
|
||||
const holdingStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO holdings (portfolio_id, ticker, name, price, change_pct, weight)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const holding of holdings) {
|
||||
holdingStmt.run("default", holding.ticker, holding.name, holding.price, holding.changePct, holding.weight);
|
||||
}
|
||||
|
||||
// Insert agents metadata
|
||||
const agentStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO agents (id, name, description, pipeline)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const agent of agents) {
|
||||
agentStmt.run(agent.id, agent.name, "", agent.pipeline ?? "research");
|
||||
}
|
||||
|
||||
// Create a model for COST
|
||||
const modelId = "model-cost-income";
|
||||
db.prepare("INSERT OR REPLACE INTO models (id, company_id, tab) VALUES (?, ?, ?)").run(modelId, activeCompanyId, "income");
|
||||
|
||||
// Insert model headers
|
||||
const headerStmt = db.prepare("INSERT OR REPLACE INTO model_headers (model_id, position, label) VALUES (?, ?, ?)");
|
||||
modelHeaders.forEach((label: string, idx: number) => {
|
||||
headerStmt.run(modelId, idx, label);
|
||||
});
|
||||
|
||||
// Insert model rows
|
||||
const rowStmt = db.prepare("INSERT OR REPLACE INTO model_rows (model_id, position, label, kind, \"values\") VALUES (?, ?, ?, ?, ?)");
|
||||
modelRows.forEach((row, idx: number) => {
|
||||
rowStmt.run(modelId, idx, row.label, row.kind, JSON.stringify(row.values));
|
||||
});
|
||||
|
||||
// Insert memo for COST
|
||||
db.prepare("INSERT OR REPLACE INTO memos (company_id, status) VALUES (?, ?)").run(activeCompanyId, "review");
|
||||
|
||||
// Insert memo sections
|
||||
const memoStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO memo_sections (id, company_id, title, content, primary_agent)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const section of memoSections) {
|
||||
memoStmt.run(section.id, activeCompanyId, section.title, section.content, section.primaryAgent ?? null);
|
||||
}
|
||||
|
||||
// Insert memo citations
|
||||
const citationStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO memo_citations (id, company_id, section_id, label, type, title, reference, verification_status, source_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const citation of memoCitations) {
|
||||
citationStmt.run(
|
||||
citation.id,
|
||||
activeCompanyId,
|
||||
citation.sectionId,
|
||||
citation.label,
|
||||
citation.type,
|
||||
citation.title,
|
||||
citation.reference,
|
||||
citation.verificationStatus,
|
||||
citation.sourceUrl ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// Insert memo annotations
|
||||
const annotationStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO memo_annotations (id, company_id, section_id, kind, selected_text, comment, created_by, created_at, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const annotation of memoAnnotations) {
|
||||
annotationStmt.run(
|
||||
annotation.id,
|
||||
activeCompanyId,
|
||||
annotation.sectionId,
|
||||
annotation.kind,
|
||||
annotation.selectedText,
|
||||
annotation.comment ?? null,
|
||||
annotation.createdBy,
|
||||
annotation.createdAt,
|
||||
annotation.status
|
||||
);
|
||||
}
|
||||
|
||||
// Insert memo section reviews
|
||||
const reviewStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO memo_section_reviews (company_id, section_id, status, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const review of memoSectionReviews) {
|
||||
reviewStmt.run(activeCompanyId, review.sectionId, review.status, review.updatedAt);
|
||||
}
|
||||
|
||||
// Insert catalysts
|
||||
const catalystStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO catalysts (id, company_id, date, event, impact, thesis_relevance, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const catalyst of demoCatalysts) {
|
||||
catalystStmt.run(
|
||||
catalyst.id,
|
||||
activeCompanyId,
|
||||
catalyst.date,
|
||||
catalyst.event,
|
||||
catalyst.impact,
|
||||
catalyst.thesisRelevance,
|
||||
catalyst.source ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// Insert alerts
|
||||
const alertStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO alerts (id, company_id, timestamp, type, description, thesis_impact, status, target_section)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const alert of demoAlerts as Alert[]) {
|
||||
alertStmt.run(
|
||||
alert.id,
|
||||
alert.companyId ?? null,
|
||||
alert.timestamp,
|
||||
alert.type,
|
||||
alert.description,
|
||||
alert.thesisImpact,
|
||||
alert.status,
|
||||
alert.targetSection ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// Insert risks
|
||||
const riskStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO risks (id, company_id, risk, category, severity, likelihood, mitigation, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const risk of demoRisks as Risk[]) {
|
||||
riskStmt.run(
|
||||
risk.id,
|
||||
risk.companyId,
|
||||
risk.risk,
|
||||
risk.category,
|
||||
risk.severity,
|
||||
risk.likelihood,
|
||||
risk.mitigation,
|
||||
risk.status
|
||||
);
|
||||
}
|
||||
|
||||
// Insert earnings schedules
|
||||
const earningsStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO earnings_schedules (id, company_id, quarter, expected_date, timing, actual_revenue, expected_revenue, actual_eps, expected_eps)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const earnings of demoEarnings as EarningsSchedule[]) {
|
||||
earningsStmt.run(
|
||||
earnings.id,
|
||||
earnings.companyId,
|
||||
earnings.quarter,
|
||||
earnings.expectedDate,
|
||||
earnings.timing ?? null,
|
||||
earnings.actualRevenue ?? null,
|
||||
earnings.expectedRevenue ?? null,
|
||||
earnings.actualEps ?? null,
|
||||
earnings.expectedEps ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// Insert filings
|
||||
const filingStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO filings (id, company_id, form_type, filed_date, title, key_changes, reviewed)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const filing of demoFilings as Filing[]) {
|
||||
filingStmt.run(
|
||||
filing.id,
|
||||
filing.companyId,
|
||||
filing.formType,
|
||||
filing.filedDate,
|
||||
filing.title,
|
||||
filing.keyChanges ?? null,
|
||||
filing.reviewed ? 1 : 0
|
||||
);
|
||||
}
|
||||
|
||||
// Insert exports
|
||||
const exportStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO export_records (id, type, title, company_id, format, status, created_at, download_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const exp of demoExports as ExportRecord[]) {
|
||||
exportStmt.run(
|
||||
exp.id,
|
||||
exp.type,
|
||||
exp.title,
|
||||
exp.companyId ?? null,
|
||||
exp.format,
|
||||
exp.status,
|
||||
exp.createdAt,
|
||||
exp.downloadUrl ?? null
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[DB] Demo data seeded successfully");
|
||||
}
|
||||
202
packages/shared/src/export/excel.ts
Normal file
202
packages/shared/src/export/excel.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Excel export functionality
|
||||
* Generates Excel workbooks from financial models
|
||||
*/
|
||||
|
||||
import ExcelJS from "exceljs";
|
||||
import type { ModelRow, Company } from "@mosaiciq/contracts/rpc";
|
||||
|
||||
export interface ExcelOptions {
|
||||
includeFormulas?: boolean;
|
||||
includeCompanyInfo?: boolean;
|
||||
freezeHeader?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export model data as Excel buffer
|
||||
*/
|
||||
export async function exportModelAsExcel(
|
||||
headers: string[],
|
||||
rows: ModelRow[],
|
||||
company: Company,
|
||||
options: ExcelOptions = {}
|
||||
): Promise<Buffer> {
|
||||
const {
|
||||
includeFormulas = true,
|
||||
includeCompanyInfo = true,
|
||||
freezeHeader = true,
|
||||
} = options;
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = "MosaicIQ";
|
||||
workbook.created = new Date();
|
||||
|
||||
// Main model worksheet
|
||||
const worksheet = workbook.addWorksheet("Financial Model");
|
||||
|
||||
// Set column widths
|
||||
const columnWidths = [30]; // Label column
|
||||
headers.forEach(() => columnWidths.push(15));
|
||||
worksheet.columns = columnWidths.map((width) => ({ width }));
|
||||
|
||||
// Header row
|
||||
const headerRow = worksheet.addRow(["", ...headers]);
|
||||
headerRow.font = { bold: true, size: 11 };
|
||||
headerRow.fill = {
|
||||
type: "pattern",
|
||||
pattern: "solid",
|
||||
fgColor: { argb: "FF4472C4" },
|
||||
};
|
||||
headerRow.alignment = { horizontal: "center" };
|
||||
headerRow.font.color = { argb: "FFFFFFFF" };
|
||||
|
||||
// Data rows
|
||||
for (const row of rows) {
|
||||
const excelRow = worksheet.addRow([row.label, ...row.values]);
|
||||
|
||||
// Style based on row kind
|
||||
if (row.kind === "total") {
|
||||
excelRow.font = { bold: true };
|
||||
excelRow.fill = {
|
||||
type: "pattern",
|
||||
pattern: "solid",
|
||||
fgColor: { argb: "FFE7E6E6" },
|
||||
};
|
||||
excelRow.border = {
|
||||
top: { style: "thin" },
|
||||
bottom: { style: "thin" },
|
||||
};
|
||||
} else if (row.kind === "forecast") {
|
||||
excelRow.font = { italic: true };
|
||||
excelRow.fill = {
|
||||
type: "pattern",
|
||||
pattern: "solid",
|
||||
fgColor: { argb: "FFFFF9E6" },
|
||||
};
|
||||
}
|
||||
|
||||
// Add formulas for calculated cells if enabled
|
||||
if (includeFormulas && (row.kind === "forecast" || row.kind === "total")) {
|
||||
row.values.forEach((value, colIndex) => {
|
||||
if (typeof value === "string" && value.startsWith("=")) {
|
||||
const cell = excelRow.getCell(colIndex + 2);
|
||||
cell.value = {
|
||||
formula: value.slice(1),
|
||||
result: 0, // Placeholder
|
||||
} as any;
|
||||
cell.numFmt = "#,##0.0";
|
||||
} else if (typeof value === "number") {
|
||||
const cell = excelRow.getCell(colIndex + 2);
|
||||
cell.numFmt = "#,##0.0";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Freeze header row if requested
|
||||
if (freezeHeader) {
|
||||
worksheet.views = [{ state: "frozen", xSplit: 0, ySplit: 1 }];
|
||||
}
|
||||
|
||||
// Add borders to all cells
|
||||
const totalRows = rows.length + 1;
|
||||
const totalCols = headers.length + 1;
|
||||
for (let row = 1; row <= totalRows; row++) {
|
||||
for (let col = 1; col <= totalCols; col++) {
|
||||
const cell = worksheet.getCell(row, col);
|
||||
if (!cell.border) {
|
||||
cell.border = {
|
||||
top: { style: "thin", color: { argb: "FFCCCCCC" } },
|
||||
left: { style: "thin", color: { argb: "FFCCCCCC" } },
|
||||
bottom: { style: "thin", color: { argb: "FFCCCCCC" } },
|
||||
right: { style: "thin", color: { argb: "FFCCCCCC" } },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add company info sheet if requested
|
||||
if (includeCompanyInfo) {
|
||||
const infoSheet = workbook.addWorksheet("Company Info");
|
||||
infoSheet.addRow(["Property", "Value"]);
|
||||
infoSheet.addRow(["Company", company.name]);
|
||||
infoSheet.addRow(["Ticker", company.ticker]);
|
||||
infoSheet.addRow(["Sector", company.sector]);
|
||||
if (company.subIndustry) {
|
||||
infoSheet.addRow(["Industry", company.subIndustry]);
|
||||
}
|
||||
infoSheet.addRow(["Export Date", new Date().toLocaleDateString()]);
|
||||
|
||||
// Style the info sheet
|
||||
infoSheet.getColumn(1).width = 20;
|
||||
infoSheet.getColumn(2).width = 40;
|
||||
infoSheet.getRow(1).font = { bold: true };
|
||||
}
|
||||
|
||||
// Add assumptions sheet
|
||||
const assumptionsSheet = workbook.addWorksheet("Assumptions");
|
||||
assumptionsSheet.addRow(["Category", "Assumption", "Value", "Notes"]);
|
||||
|
||||
// Add some common assumptions (in real implementation, these would come from the model)
|
||||
const commonAssumptions = [
|
||||
["Growth", "Revenue Growth Rate", "See model", "Per segment"],
|
||||
["Margins", "Gross Margin Target", "Company guidance", "Industry benchmark"],
|
||||
["Rates", "Tax Rate", "21%", "US federal"],
|
||||
["Rates", "Discount Rate (WACC)", "8-10%", "Industry dependent"],
|
||||
];
|
||||
|
||||
for (const assumption of commonAssumptions) {
|
||||
assumptionsSheet.addRow(assumption);
|
||||
}
|
||||
|
||||
assumptionsSheet.getColumn(1).width = 20;
|
||||
assumptionsSheet.getColumn(2).width = 30;
|
||||
assumptionsSheet.getColumn(3).width = 20;
|
||||
assumptionsSheet.getColumn(4).width = 40;
|
||||
assumptionsSheet.getRow(1).font = { bold: true };
|
||||
|
||||
// Generate buffer
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate filename for model export
|
||||
*/
|
||||
export function getModelFilename(company: Company, tab: string): string {
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
return `${company.ticker}_${tab}_Model_${date}.xlsx`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format cell value for Excel
|
||||
*/
|
||||
export function formatCellValue(value: string | number): { value: string | number | any; numFmt?: string } {
|
||||
if (typeof value === "number") {
|
||||
return {
|
||||
value,
|
||||
numFmt: "#,##0.0",
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's a percentage
|
||||
if (typeof value === "string" && value.includes("%")) {
|
||||
const numValue = parseFloat(value.replace("%", "")) / 100;
|
||||
return {
|
||||
value: numValue,
|
||||
numFmt: "0.0%",
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's a formula
|
||||
if (typeof value === "string" && value.startsWith("=")) {
|
||||
return {
|
||||
value: {
|
||||
formula: value.slice(1),
|
||||
result: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { value };
|
||||
}
|
||||
7
packages/shared/src/export/index.ts
Normal file
7
packages/shared/src/export/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Export module exports
|
||||
*/
|
||||
|
||||
export * from "./pdf.js";
|
||||
export * from "./excel.js";
|
||||
export * from "./pptx.js";
|
||||
262
packages/shared/src/export/pdf.ts
Normal file
262
packages/shared/src/export/pdf.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* PDF export functionality
|
||||
* Generates HTML for PDF generation using Electron's printToPDF
|
||||
*/
|
||||
|
||||
import type { Memo, Company } from "@mosaiciq/contracts/rpc";
|
||||
|
||||
export interface PDFOptions {
|
||||
includeAnnotations?: boolean;
|
||||
includeCitations?: boolean;
|
||||
fontSize?: number;
|
||||
marginTop?: string;
|
||||
marginBottom?: string;
|
||||
landscape?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export memo as HTML for PDF generation
|
||||
* In the desktop app, this HTML can be rendered and printed to PDF using webContents.printToPDF()
|
||||
*/
|
||||
export async function exportMemoAsHTML(
|
||||
memo: Memo,
|
||||
company: Company,
|
||||
options: PDFOptions = {}
|
||||
): Promise<string> {
|
||||
const {
|
||||
includeAnnotations = true,
|
||||
includeCitations = true,
|
||||
fontSize = 11,
|
||||
} = options;
|
||||
|
||||
const generatedDate = new Date().toLocaleDateString();
|
||||
|
||||
let html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${company.name} - Investment Memo</title>
|
||||
<style>
|
||||
@page {
|
||||
size: Letter;
|
||||
margin: 0.5in;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: ${fontSize}pt;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 8in;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #333;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24pt;
|
||||
margin: 0 0 5px 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.header .meta {
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 25px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #999;
|
||||
padding-bottom: 3px;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
text-align: justify;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.citation {
|
||||
font-size: 8pt;
|
||||
color: #666;
|
||||
vertical-align: super;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.annotation-highlight {
|
||||
background-color: #fff3cd;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.annotation-strike {
|
||||
text-decoration: line-through;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.annotation-comment {
|
||||
background-color: #e7f3ff;
|
||||
border-left: 2px solid #0066cc;
|
||||
padding: 5px 10px;
|
||||
margin: 10px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.citations-list {
|
||||
margin-top: 30px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #999;
|
||||
}
|
||||
|
||||
.citations-list h3 {
|
||||
font-size: 12pt;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.citation-item {
|
||||
font-size: 9pt;
|
||||
margin-bottom: 5px;
|
||||
padding-left: 20px;
|
||||
text-indent: -15px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 8pt;
|
||||
color: #999;
|
||||
margin-top: 30px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #ccc;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.section {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>${company.name} (${company.ticker})</h1>
|
||||
<div class="meta">Investment Research Memo • Generated: ${generatedDate}</div>
|
||||
<div class="meta">${company.sector}${company.subIndustry ? ` - ${company.subIndustry}` : ""}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add memo sections
|
||||
for (const section of memo.sections) {
|
||||
html += `
|
||||
<div class="section">
|
||||
<h2>${section.title}</h2>
|
||||
<div class="section-content">${formatContent(section.content, memo.citations, memo.annotations, {
|
||||
includeAnnotations,
|
||||
includeCitations,
|
||||
})}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add citations if requested
|
||||
if (includeCitations && memo.citations.length > 0) {
|
||||
html += `
|
||||
<div class="citations-list">
|
||||
<h3>Sources</h3>
|
||||
`;
|
||||
for (const citation of memo.citations) {
|
||||
html += ` <div class="citation-item">${citation.id}. ${citation.title} - ${citation.reference}</div>\n`;
|
||||
}
|
||||
html += ` </div>\n`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="footer">
|
||||
This document was generated by MosaicIQ Research Platform<br>
|
||||
Confidential - For internal use only
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format content with citations and annotations
|
||||
*/
|
||||
function formatContent(
|
||||
content: string,
|
||||
citations: any[],
|
||||
annotations: any[],
|
||||
options: {
|
||||
includeAnnotations: boolean;
|
||||
includeCitations: boolean;
|
||||
}
|
||||
): string {
|
||||
let formatted = content;
|
||||
|
||||
// Add citation markers
|
||||
if (options.includeCitations) {
|
||||
formatted = formatted.replace(/\[(\d+)\]/g, '<span class="citation">[$1]</span>');
|
||||
}
|
||||
|
||||
// Process annotations (simplified - in real implementation would parse positions)
|
||||
if (options.includeAnnotations) {
|
||||
for (const annotation of annotations) {
|
||||
if (annotation.kind === "highlight") {
|
||||
formatted = formatted.replace(
|
||||
annotation.selectedText,
|
||||
`<span class="annotation-highlight">${annotation.selectedText}</span>`
|
||||
);
|
||||
} else if (annotation.kind === "strike") {
|
||||
formatted = formatted.replace(
|
||||
annotation.selectedText,
|
||||
`<span class="annotation-strike">${annotation.selectedText}</span>`
|
||||
);
|
||||
} else if (annotation.kind === "comment" && annotation.comment) {
|
||||
formatted = formatted.replace(
|
||||
annotation.selectedText,
|
||||
`${annotation.selectedText}<div class="annotation-comment">${annotation.comment}</div>`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get print-friendly URL for memo
|
||||
* This can be used with Electron's webContents.printToPDF()
|
||||
*/
|
||||
export function getPrintURL(companyId: string): string {
|
||||
return `/#/memo/${companyId}/print`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate filename for memo export
|
||||
*/
|
||||
export function getMemoFilename(company: Company, timestamp?: string): string {
|
||||
const date = timestamp || new Date().toISOString().split("T")[0];
|
||||
return `${company.ticker}_${company.name.replace(/\s+/g, "_")}_Memo_${date}.pdf`;
|
||||
}
|
||||
435
packages/shared/src/export/pptx.ts
Normal file
435
packages/shared/src/export/pptx.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* PowerPoint export functionality
|
||||
* Generates presentation decks from memos and models
|
||||
*/
|
||||
|
||||
import PptxGenJS from "pptxgenjs";
|
||||
import type { Memo, Company } from "@mosaiciq/contracts/rpc";
|
||||
|
||||
export interface PPTXOptions {
|
||||
includeCharts?: boolean;
|
||||
includeModel?: boolean;
|
||||
maxBulletPoints?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export memo as PowerPoint presentation
|
||||
*/
|
||||
export async function exportPresentation(
|
||||
memo: Memo,
|
||||
company: Company,
|
||||
options: PPTXOptions = {}
|
||||
): Promise<Buffer> {
|
||||
const {
|
||||
includeCharts = true,
|
||||
includeModel = true,
|
||||
maxBulletPoints = 5,
|
||||
} = options;
|
||||
|
||||
const pptx = new (PptxGenJS as any)();
|
||||
pptx.author = "MosaicIQ";
|
||||
pptx.company = "MosaicIQ Research";
|
||||
pptx.title = `${company.name} Investment Committee Presentation`;
|
||||
pptx.subject = "Investment Research";
|
||||
|
||||
const COLORS = {
|
||||
primary: "4472C4",
|
||||
secondary: "6C757D",
|
||||
accent: "28A745",
|
||||
warning: "FFC107",
|
||||
danger: "DC3545",
|
||||
dark: "363636",
|
||||
light: "F8F9FA",
|
||||
};
|
||||
|
||||
// Slide 1: Title slide
|
||||
addTitleSlide(pptx, company, COLORS);
|
||||
|
||||
// Slide 2: Investment Thesis
|
||||
const thesisSection = memo.sections.find((s) => s.id === "thesis");
|
||||
if (thesisSection) {
|
||||
addThesisSlide(pptx, thesisSection, COLORS);
|
||||
}
|
||||
|
||||
// Slide 3: Key Drivers
|
||||
const driversSection = memo.sections.find((s) => s.id === "drivers");
|
||||
if (driversSection) {
|
||||
addDriversSlide(pptx, driversSection, COLORS, maxBulletPoints);
|
||||
}
|
||||
|
||||
// Slide 4: Business Quality
|
||||
const qualitySection = memo.sections.find((s) => s.id === "quality");
|
||||
if (qualitySection) {
|
||||
addContentSlide(pptx, "Business Quality", qualitySection.content, COLORS);
|
||||
}
|
||||
|
||||
// Slide 5: Financial Summary
|
||||
const financialsSection = memo.sections.find((s) => s.id === "financials");
|
||||
if (financialsSection) {
|
||||
addContentSlide(pptx, "Financial Summary", financialsSection.content, COLORS);
|
||||
}
|
||||
|
||||
// Slide 6: Valuation
|
||||
const valuationSection = memo.sections.find((s) => s.id === "valuation");
|
||||
if (valuationSection) {
|
||||
addValuationSlide(pptx, valuationSection, COLORS);
|
||||
}
|
||||
|
||||
// Slide 7: Key Risks
|
||||
const risksSection = memo.sections.find((s) => s.id === "risks");
|
||||
if (risksSection) {
|
||||
addRisksSlide(pptx, risksSection.content, COLORS, maxBulletPoints);
|
||||
}
|
||||
|
||||
// Slide 8: Catalysts
|
||||
const catalystsSection = memo.sections.find((s) => s.id === "catalysts");
|
||||
if (catalystsSection) {
|
||||
addCatalystsSlide(pptx, catalystsSection.content, COLORS, maxBulletPoints);
|
||||
}
|
||||
|
||||
// Slide 9: Conclusion/Recommendation
|
||||
addConclusionSlide(pptx, company, COLORS);
|
||||
|
||||
// Generate buffer
|
||||
const buffer = await pptx.write({ outputType: "nodebuffer", compression: false }) as Buffer;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function addTitleSlide(pptx: any, company: Company, COLORS: any) {
|
||||
const slide = pptx.addSlide();
|
||||
|
||||
// Background color
|
||||
slide.background = { color: COLORS.light };
|
||||
|
||||
// Company name and ticker
|
||||
slide.addText(company.name, {
|
||||
x: 0.5,
|
||||
y: 1.5,
|
||||
w: 9,
|
||||
h: 0.8,
|
||||
fontSize: 44,
|
||||
bold: true,
|
||||
color: COLORS.dark,
|
||||
fontFace: "Arial",
|
||||
});
|
||||
|
||||
slide.addText(`(${company.ticker})`, {
|
||||
x: 0.5,
|
||||
y: 2.2,
|
||||
w: 9,
|
||||
h: 0.4,
|
||||
fontSize: 24,
|
||||
color: COLORS.secondary,
|
||||
fontFace: "Arial",
|
||||
});
|
||||
|
||||
// Subtitle
|
||||
slide.addText("Investment Committee Presentation", {
|
||||
x: 0.5,
|
||||
y: 3.0,
|
||||
w: 9,
|
||||
h: 0.5,
|
||||
fontSize: 20,
|
||||
color: COLORS.primary,
|
||||
fontFace: "Arial",
|
||||
});
|
||||
|
||||
// Date
|
||||
slide.addText(new Date().toLocaleDateString(), {
|
||||
x: 0.5,
|
||||
y: 3.6,
|
||||
w: 9,
|
||||
h: 0.4,
|
||||
fontSize: 16,
|
||||
color: COLORS.secondary,
|
||||
fontFace: "Arial",
|
||||
});
|
||||
|
||||
// Divider line
|
||||
slide.addShape("line", {
|
||||
x: 0.5,
|
||||
y: 4.2,
|
||||
w: 9,
|
||||
h: 0,
|
||||
line: { color: COLORS.primary, width: 2 },
|
||||
});
|
||||
|
||||
// Sector info
|
||||
slide.addText(`Sector: ${company.sector}`, {
|
||||
x: 0.5,
|
||||
y: 4.5,
|
||||
w: 9,
|
||||
h: 0.4,
|
||||
fontSize: 14,
|
||||
color: COLORS.secondary,
|
||||
fontFace: "Arial",
|
||||
});
|
||||
}
|
||||
|
||||
function addThesisSlide(pptx: any, section: any, COLORS: any) {
|
||||
const slide = pptx.addSlide();
|
||||
|
||||
// Title
|
||||
slide.addText("Investment Thesis", {
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
w: 9,
|
||||
h: 0.5,
|
||||
fontSize: 32,
|
||||
bold: true,
|
||||
color: COLORS.primary,
|
||||
fontFace: "Arial",
|
||||
});
|
||||
|
||||
// Content (truncated if too long)
|
||||
const content = section.content.slice(0, 600) + (section.content.length > 600 ? "..." : "");
|
||||
slide.addText(content, {
|
||||
x: 0.5,
|
||||
y: 1.2,
|
||||
w: 9,
|
||||
h: 4,
|
||||
fontSize: 18,
|
||||
color: COLORS.dark,
|
||||
fontFace: "Arial",
|
||||
align: "justify",
|
||||
});
|
||||
}
|
||||
|
||||
function addDriversSlide(pptx: any, section: any, COLORS: any, maxBullets: number) {
|
||||
const slide = pptx.addSlide();
|
||||
|
||||
// Title
|
||||
slide.addText("Key Value Drivers", {
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
w: 9,
|
||||
h: 0.5,
|
||||
fontSize: 32,
|
||||
bold: true,
|
||||
color: COLORS.primary,
|
||||
fontFace: "Arial",
|
||||
});
|
||||
|
||||
// Parse bullets (simplified - assumes line breaks separate bullets)
|
||||
const lines = section.content.split("\n").filter((l: string) => l.trim().length > 0);
|
||||
const bullets = lines.slice(0, maxBullets);
|
||||
|
||||
bullets.forEach((bullet: string, index: number) => {
|
||||
slide.addText(bullet.trim(), {
|
||||
x: 0.5,
|
||||
y: 1.2 + index * 0.5,
|
||||
w: 9,
|
||||
h: 0.4,
|
||||
fontSize: 18,
|
||||
color: COLORS.dark,
|
||||
fontFace: "Arial",
|
||||
bullet: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addValuationSlide(pptx: any, section: any, COLORS: any) {
|
||||
const slide = pptx.addSlide();
|
||||
|
||||
// Title
|
||||
slide.addText("Valuation", {
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
w: 9,
|
||||
h: 0.5,
|
||||
fontSize: 32,
|
||||
bold: true,
|
||||
color: COLORS.primary,
|
||||
fontFace: "Arial",
|
||||
});
|
||||
|
||||
// Content
|
||||
const content = section.content.slice(0, 500);
|
||||
slide.addText(content, {
|
||||
x: 0.5,
|
||||
y: 1.2,
|
||||
w: 9,
|
||||
h: 3,
|
||||
fontSize: 16,
|
||||
color: COLORS.dark,
|
||||
fontFace: "Arial",
|
||||
align: "justify",
|
||||
});
|
||||
|
||||
// Valuation summary box
|
||||
slide.addShape("rect", {
|
||||
x: 0.5,
|
||||
y: 4.5,
|
||||
w: 9,
|
||||
h: 1.5,
|
||||
fill: { color: COLORS.light },
|
||||
line: { color: COLORS.primary, width: 1 },
|
||||
});
|
||||
|
||||
slide.addText("Valuation Summary", {
|
||||
x: 0.7,
|
||||
y: 4.7,
|
||||
w: 8.6,
|
||||
h: 0.3,
|
||||
fontSize: 14,
|
||||
bold: true,
|
||||
color: COLORS.primary,
|
||||
fontFace: "Arial",
|
||||
});
|
||||
}
|
||||
|
||||
function addRisksSlide(pptx: any, content: string, COLORS: any, maxBullets: number) {
|
||||
const slide = pptx.addSlide();
|
||||
|
||||
// Title
|
||||
slide.addText("Key Risks", {
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
w: 9,
|
||||
h: 0.5,
|
||||
fontSize: 32,
|
||||
bold: true,
|
||||
color: COLORS.danger,
|
||||
fontFace: "Arial",
|
||||
});
|
||||
|
||||
// Parse bullets
|
||||
const lines = content.split("\n").filter((l) => l.trim().length > 0);
|
||||
const bullets = lines.slice(0, maxBullets);
|
||||
|
||||
bullets.forEach((bullet, index) => {
|
||||
slide.addText(bullet.trim(), {
|
||||
x: 0.5,
|
||||
y: 1.2 + index * 0.5,
|
||||
w: 9,
|
||||
h: 0.4,
|
||||
fontSize: 16,
|
||||
color: COLORS.dark,
|
||||
fontFace: "Arial",
|
||||
bullet: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addCatalystsSlide(pptx: any, content: string, COLORS: any, maxBullets: number) {
|
||||
const slide = pptx.addSlide();
|
||||
|
||||
// Title
|
||||
slide.addText("Upcoming Catalysts", {
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
w: 9,
|
||||
h: 0.5,
|
||||
fontSize: 32,
|
||||
bold: true,
|
||||
color: COLORS.accent,
|
||||
fontFace: "Arial",
|
||||
});
|
||||
|
||||
// Parse bullets
|
||||
const lines = content.split("\n").filter((l) => l.trim().length > 0);
|
||||
const bullets = lines.slice(0, maxBullets);
|
||||
|
||||
bullets.forEach((bullet, index) => {
|
||||
slide.addText(bullet.trim(), {
|
||||
x: 0.5,
|
||||
y: 1.2 + index * 0.5,
|
||||
w: 9,
|
||||
h: 0.4,
|
||||
fontSize: 16,
|
||||
color: COLORS.dark,
|
||||
fontFace: "Arial",
|
||||
bullet: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addContentSlide(pptx: any, title: string, content: string, COLORS: any) {
|
||||
const slide = pptx.addSlide();
|
||||
|
||||
// Title
|
||||
slide.addText(title, {
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
w: 9,
|
||||
h: 0.5,
|
||||
fontSize: 32,
|
||||
bold: true,
|
||||
color: COLORS.primary,
|
||||
fontFace: "Arial",
|
||||
});
|
||||
|
||||
// Content
|
||||
const truncated = content.slice(0, 700);
|
||||
slide.addText(truncated, {
|
||||
x: 0.5,
|
||||
y: 1.2,
|
||||
w: 9,
|
||||
h: 5,
|
||||
fontSize: 16,
|
||||
color: COLORS.dark,
|
||||
fontFace: "Arial",
|
||||
align: "justify",
|
||||
});
|
||||
}
|
||||
|
||||
function addConclusionSlide(pptx: any, company: Company, COLORS: any) {
|
||||
const slide = pptx.addSlide();
|
||||
|
||||
// Title
|
||||
slide.addText("Conclusion & Recommendation", {
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
w: 9,
|
||||
h: 0.5,
|
||||
fontSize: 32,
|
||||
bold: true,
|
||||
color: COLORS.primary,
|
||||
fontFace: "Arial",
|
||||
});
|
||||
|
||||
// Recommendation box
|
||||
slide.addShape("rect", {
|
||||
x: 0.5,
|
||||
y: 1.2,
|
||||
w: 9,
|
||||
h: 2,
|
||||
fill: { color: "E7F3FF" },
|
||||
line: { color: COLORS.primary, width: 2 },
|
||||
});
|
||||
|
||||
slide.addText("Investment Recommendation", {
|
||||
x: 0.7,
|
||||
y: 1.4,
|
||||
w: 8.6,
|
||||
h: 0.4,
|
||||
fontSize: 18,
|
||||
bold: true,
|
||||
color: COLORS.primary,
|
||||
fontFace: "Arial",
|
||||
});
|
||||
|
||||
// Disclaimer
|
||||
slide.addText(
|
||||
"This presentation is for informational purposes only and does not constitute investment advice. " +
|
||||
"Please refer to the full investment memo for detailed analysis and disclosures.",
|
||||
{
|
||||
x: 0.5,
|
||||
y: 5.5,
|
||||
w: 9,
|
||||
h: 1,
|
||||
fontSize: 10,
|
||||
color: COLORS.secondary,
|
||||
fontFace: "Arial",
|
||||
align: "center",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate filename for presentation export
|
||||
*/
|
||||
export function getPresentationFilename(company: Company): string {
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
return `${company.ticker}_Presentation_${date}.pptx`;
|
||||
}
|
||||
207
packages/shared/src/llm/client.ts
Normal file
207
packages/shared/src/llm/client.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* LLM client for Pi API (Inflection AI)
|
||||
* Provides streaming and non-streaming completion functions
|
||||
*/
|
||||
|
||||
const PI_API_URL = "https://api.pi.ai/v1/chat/completions";
|
||||
const client = {
|
||||
apiKey: process.env.PI_API_KEY || "",
|
||||
};
|
||||
|
||||
export interface StreamOptions {
|
||||
onProgress?: (text: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface CompletionOptions {
|
||||
model?: string;
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL = "pi";
|
||||
const DEFAULT_MAX_TOKENS = 4096;
|
||||
|
||||
/**
|
||||
* Stream a response from Pi
|
||||
*/
|
||||
export async function streamResponse(
|
||||
prompt: string,
|
||||
options: StreamOptions & CompletionOptions = {}
|
||||
): Promise<string> {
|
||||
const {
|
||||
model = DEFAULT_MODEL,
|
||||
maxTokens = DEFAULT_MAX_TOKENS,
|
||||
temperature = 0,
|
||||
onProgress,
|
||||
signal,
|
||||
} = options;
|
||||
|
||||
if (!client.apiKey) {
|
||||
throw new Error("PI_API_KEY is not set");
|
||||
}
|
||||
|
||||
const response = await fetch(PI_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${client.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
stream: true,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Pi API error: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("Failed to get response body reader");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let fullResponse = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ") && line !== "data: [DONE]") {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
const content = data.choices?.[0]?.delta?.content;
|
||||
if (content) {
|
||||
fullResponse += content;
|
||||
onProgress?.(content);
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fullResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a complete response from Pi (non-streaming)
|
||||
*/
|
||||
export async function complete(
|
||||
prompt: string,
|
||||
options: CompletionOptions = {}
|
||||
): Promise<string> {
|
||||
const {
|
||||
model = DEFAULT_MODEL,
|
||||
maxTokens = DEFAULT_MAX_TOKENS,
|
||||
temperature = 0,
|
||||
} = options;
|
||||
|
||||
if (!client.apiKey) {
|
||||
throw new Error("PI_API_KEY is not set");
|
||||
}
|
||||
|
||||
const response = await fetch(PI_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${client.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Pi API error: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.choices?.[0]?.message?.content ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if API key is configured
|
||||
*/
|
||||
export function isConfigured(): boolean {
|
||||
return !!client.apiKey && client.apiKey.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream structured JSON response
|
||||
* Useful for agents that need to return structured data
|
||||
*/
|
||||
export async function streamStructuredResponse<T>(
|
||||
prompt: string,
|
||||
schema: Record<string, unknown>,
|
||||
options: StreamOptions & CompletionOptions = {}
|
||||
): Promise<T> {
|
||||
const structuredPrompt = `${prompt}
|
||||
|
||||
Please respond with a JSON object that follows this structure:
|
||||
${JSON.stringify(schema, null, 2)}
|
||||
|
||||
Your response must be valid JSON only, with no additional text or explanation.`;
|
||||
|
||||
const response = await streamResponse(structuredPrompt, options);
|
||||
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
return JSON.parse(response) as T;
|
||||
} catch (error) {
|
||||
// If the response isn't valid JSON, try to extract JSON from the response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]) as T;
|
||||
}
|
||||
throw new Error("Failed to parse structured response as JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete with structured JSON response (non-streaming)
|
||||
*/
|
||||
export async function completeStructured<T>(
|
||||
prompt: string,
|
||||
schema: Record<string, unknown>,
|
||||
options: CompletionOptions = {}
|
||||
): Promise<T> {
|
||||
const structuredPrompt = `${prompt}
|
||||
|
||||
Please respond with a JSON object that follows this structure:
|
||||
${JSON.stringify(schema, null, 2)}
|
||||
|
||||
Your response must be valid JSON only, with no additional text or explanation.`;
|
||||
|
||||
const response = await complete(structuredPrompt, options);
|
||||
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
return JSON.parse(response) as T;
|
||||
} catch (error) {
|
||||
// If the response isn't valid JSON, try to extract JSON from the response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]) as T;
|
||||
}
|
||||
throw new Error("Failed to parse structured response as JSON");
|
||||
}
|
||||
}
|
||||
|
||||
export default client;
|
||||
145
packages/shared/src/llm/context.ts
Normal file
145
packages/shared/src/llm/context.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Agent context builder
|
||||
* Gathers relevant data for agent execution
|
||||
*/
|
||||
|
||||
import type { Db } from "../db/database.js";
|
||||
import type { AgentContext } from "./prompts.js";
|
||||
import {
|
||||
getCompany,
|
||||
getCompanyByTicker,
|
||||
listFilings,
|
||||
getMemo,
|
||||
getModel,
|
||||
} from "../db/queries.js";
|
||||
|
||||
export interface BuildContextOptions {
|
||||
includeHistoricalData?: boolean;
|
||||
includeModel?: boolean;
|
||||
userMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build agent context for a specific company
|
||||
*/
|
||||
export async function buildAgentContext(
|
||||
db: Db,
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
options: BuildContextOptions = {}
|
||||
): Promise<AgentContext> {
|
||||
const {
|
||||
includeHistoricalData = true,
|
||||
includeModel = true,
|
||||
userMessage,
|
||||
} = options;
|
||||
|
||||
// Get company data
|
||||
const company = getCompany(db, companyId) || getCompanyByTicker(db, companyId);
|
||||
if (!company) {
|
||||
throw new Error(`Company not found: ${companyId}`);
|
||||
}
|
||||
|
||||
// Get filings
|
||||
const filings = listFilings(db, companyId, undefined).slice(0, 20);
|
||||
|
||||
// Get memo sections
|
||||
const memoData = getMemo(db, companyId);
|
||||
const memo = {
|
||||
sections: memoData.sections,
|
||||
};
|
||||
|
||||
// Get model if needed
|
||||
let model;
|
||||
if (includeModel) {
|
||||
try {
|
||||
model = getModel(db, companyId, "income");
|
||||
} catch {
|
||||
model = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Get historical data if needed
|
||||
let historicalData;
|
||||
if (includeHistoricalData) {
|
||||
historicalData = await getHistoricalData(db, companyId);
|
||||
}
|
||||
|
||||
return {
|
||||
company: {
|
||||
name: company.name,
|
||||
ticker: company.ticker,
|
||||
sector: company.sector,
|
||||
},
|
||||
filings: filings.map((f) => ({
|
||||
formType: f.formType,
|
||||
title: f.title,
|
||||
filedDate: f.filedDate,
|
||||
})),
|
||||
memo,
|
||||
model,
|
||||
historicalData,
|
||||
userMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical data for a company
|
||||
*/
|
||||
async function getHistoricalData(db: Db, companyId: string): Promise<Record<string, unknown>> {
|
||||
// Try to get historical data from the model
|
||||
try {
|
||||
const model = getModel(db, companyId, "income");
|
||||
|
||||
// Extract historical actuals from the model
|
||||
const historical: Record<string, unknown> = {};
|
||||
|
||||
model.rows.forEach((row) => {
|
||||
if (row.kind === "actual") {
|
||||
historical[row.label] = row.values;
|
||||
}
|
||||
});
|
||||
|
||||
return historical;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context for chat agent
|
||||
*/
|
||||
export async function buildChatContext(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
userMessage: string
|
||||
): Promise<AgentContext> {
|
||||
return buildAgentContext(db, "chat", companyId, {
|
||||
includeHistoricalData: true,
|
||||
includeModel: true,
|
||||
userMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build minimal context for quick operations
|
||||
*/
|
||||
export async function buildMinimalContext(
|
||||
db: Db,
|
||||
companyId: string
|
||||
): Promise<Partial<AgentContext>> {
|
||||
const company = getCompany(db, companyId) || getCompanyByTicker(db, companyId);
|
||||
if (!company) {
|
||||
throw new Error(`Company not found: ${companyId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
company: {
|
||||
name: company.name,
|
||||
ticker: company.ticker,
|
||||
sector: company.sector,
|
||||
},
|
||||
filings: [],
|
||||
memo: { sections: [] },
|
||||
};
|
||||
}
|
||||
7
packages/shared/src/llm/index.ts
Normal file
7
packages/shared/src/llm/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* LLM module exports
|
||||
*/
|
||||
|
||||
export * from "./client.js";
|
||||
export * from "./prompts.js";
|
||||
export * from "./context.js";
|
||||
411
packages/shared/src/llm/prompts.ts
Normal file
411
packages/shared/src/llm/prompts.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Agent prompt templates for MosaicIQ
|
||||
* Each agent has a specific role and prompt template
|
||||
*/
|
||||
|
||||
export interface AgentContext {
|
||||
company: {
|
||||
name: string;
|
||||
ticker: string;
|
||||
sector: string;
|
||||
};
|
||||
filings: Array<{
|
||||
formType: string;
|
||||
title: string;
|
||||
filedDate: string;
|
||||
}>;
|
||||
memo: {
|
||||
sections: Array<{ id: string; title: string; content: string }>;
|
||||
};
|
||||
model?: {
|
||||
headers: string[];
|
||||
rows: Array<{ label: string; kind: string; values: string[] }>;
|
||||
};
|
||||
historicalData?: Record<string, unknown>;
|
||||
userMessage?: string;
|
||||
}
|
||||
|
||||
export type AgentPrompt = (context: AgentContext) => string;
|
||||
|
||||
const AGENT_PROMPTS: Record<string, AgentPrompt> = {
|
||||
// SEC Filings Agent (sf)
|
||||
sf: (ctx) => `You are the SEC Filings Agent for ${ctx.company.name} (${ctx.company.ticker}).
|
||||
Your task is to analyze SEC filings and extract structured information.
|
||||
|
||||
**Available Filings:**
|
||||
${ctx.filings.map((f) => `- ${f.formType}: ${f.title} (${f.filedDate})`).join("\n")}
|
||||
|
||||
**Instructions:**
|
||||
1. Extract segment revenue data from the 10-K
|
||||
2. Identify key risk factors
|
||||
3. Note any changes in accounting policies
|
||||
4. Cite specific sections when making claims
|
||||
|
||||
**Output Format:**
|
||||
Respond with a JSON object containing:
|
||||
- extractedData: Key findings from filings
|
||||
- sources: List of filings and sections referenced
|
||||
- assumptions: Any assumptions made during analysis
|
||||
- confidence: "high" | "medium" | "low"
|
||||
|
||||
Focus on the most recent 10-K and 10-Q filings.`,
|
||||
|
||||
// Company Research Agent (cr)
|
||||
cr: (ctx) => `You are the Company Research Agent for ${ctx.company.name} (${ctx.company.ticker}).
|
||||
Analyze the company's business model and competitive position.
|
||||
|
||||
**Company Info:**
|
||||
- Name: ${ctx.company.name}
|
||||
- Ticker: ${ctx.company.ticker}
|
||||
- Sector: ${ctx.company.sector}
|
||||
|
||||
**Available Filings:**
|
||||
${ctx.filings.map((f) => `- ${f.formType}: ${f.title}`).join("\n")}
|
||||
|
||||
**Instructions:**
|
||||
1. Summarize the company's business model
|
||||
2. Identify key competitive advantages
|
||||
3. Analyze market position
|
||||
4. Note recent strategic developments
|
||||
|
||||
**Output Format:**
|
||||
Respond with a JSON object containing:
|
||||
- businessModel: Description of how the company makes money
|
||||
- competitiveAdvantages: Array of key advantages
|
||||
- marketPosition: Assessment of market position
|
||||
- recentDevelopments: Important recent changes
|
||||
- sources: Citations for your claims`,
|
||||
|
||||
// Financial Modeling Agent (fm)
|
||||
fm: (ctx) => `You are the Financial Modeling Agent for ${ctx.company.name}.
|
||||
Build a revenue model based on the company's filings and historical data.
|
||||
|
||||
**Historical Data:**
|
||||
${JSON.stringify(ctx.historicalData || {}, null, 2)}
|
||||
|
||||
**Current Model (if available):**
|
||||
${ctx.model ? JSON.stringify({ headers: ctx.model.headers, rows: ctx.model.rows.slice(0, 10) }, null, 2) : "No existing model"}
|
||||
|
||||
**Instructions:**
|
||||
1. Project revenue for the next 3 years
|
||||
2. Model key margin drivers
|
||||
3. Document your assumptions clearly
|
||||
4. Flag any sensitive assumptions
|
||||
|
||||
**Output Format:**
|
||||
Respond with a JSON object containing:
|
||||
- projections: Revenue and margin projections by year
|
||||
- keyAssumptions: Array of key assumptions with rationale
|
||||
- sensitivityAnalysis: Impact of key assumption changes
|
||||
- confidence: Assessment of projection reliability`,
|
||||
|
||||
// Valuation Agent (va)
|
||||
va: (ctx) => `You are the Valuation Agent for ${ctx.company.name}.
|
||||
Provide a comprehensive valuation analysis.
|
||||
|
||||
**Company Info:**
|
||||
- Name: ${ctx.company.name} (${ctx.company.ticker})
|
||||
- Sector: ${ctx.company.sector}
|
||||
|
||||
**Memo Sections:**
|
||||
${ctx.memo.sections.map((s) => `- ${s.title}: ${s.content.slice(0, 100)}...`).join("\n")}
|
||||
|
||||
**Instructions:**
|
||||
1. Apply appropriate valuation methodologies (DCF, multiples, etc.)
|
||||
2. Provide a fair value estimate with range
|
||||
3. Identify key value drivers
|
||||
4. Discuss upside/downside scenarios
|
||||
|
||||
**Output Format:**
|
||||
Respond with a JSON object containing:
|
||||
- methodologies: Array of valuation methods used
|
||||
- fairValue: Estimated fair value with range
|
||||
- keyDrivers: Factors driving valuation
|
||||
- scenarios: Bull, base, and bear case scenarios
|
||||
- assumptions: Critical assumptions`,
|
||||
|
||||
// Memo Writing Agent (mw)
|
||||
mw: (ctx) => `You are the Memo Writing Agent for ${ctx.company.name}.
|
||||
Synthesize research into a clear investment memo.
|
||||
|
||||
**Current Memo Sections:**
|
||||
${ctx.memo.sections.map((s) => `## ${s.title}\n${s.content}`).join("\n\n")}
|
||||
|
||||
**Instructions:**
|
||||
1. Review and refine the thesis statement
|
||||
2. Ensure all sections are coherent and well-structured
|
||||
3. Check for clarity and persuasiveness
|
||||
4. Maintain professional tone
|
||||
|
||||
**Output Format:**
|
||||
Respond with a JSON object containing:
|
||||
- refinedSections: Updated memo sections
|
||||
- suggestions: Improvements to consider
|
||||
- consistencyCheck: Notes on consistency issues
|
||||
- overallQuality: Assessment of memo quality`,
|
||||
|
||||
// Presentation Agent (pa)
|
||||
pa: (ctx) => `You are the Presentation Agent for ${ctx.company.name}.
|
||||
Create investment committee presentation materials.
|
||||
|
||||
**Memo Content:**
|
||||
${ctx.memo.sections.map((s) => `## ${s.title}\n${s.content.slice(0, 200)}...`).join("\n\n")}
|
||||
|
||||
**Instructions:**
|
||||
1. Structure presentation for investment committee
|
||||
2. Create slide outlines with key points
|
||||
3. Ensure visual clarity and impact
|
||||
4. Include supporting data
|
||||
|
||||
**Output Format:**
|
||||
Respond with a JSON object containing:
|
||||
- slideOutline: Array of slide titles and bullet points
|
||||
- keyCharts: Suggested charts and visualizations
|
||||
- talkingPoints: Key points for each slide
|
||||
- timing: Recommended time per slide`,
|
||||
|
||||
// Earnings Call Agent (ec)
|
||||
ec: (ctx) => `You are the Earnings Call Agent for ${ctx.company.name}.
|
||||
Analyze earnings call transcripts for insights.
|
||||
|
||||
**Instructions:**
|
||||
1. Parse the most recent earnings call transcript
|
||||
2. Extract management guidance
|
||||
3. Identify tone changes
|
||||
4. Note key Q&A insights
|
||||
|
||||
**Output Format:**
|
||||
Respond with a JSON object containing:
|
||||
- guidance: Management's forward guidance
|
||||
- keyInsights: Important insights from the call
|
||||
- toneAnalysis: Assessment of management tone
|
||||
- qAHighlights: Key points from Q&A
|
||||
- actionItems: Items requiring follow-up`,
|
||||
|
||||
// Competitive Intel Agent (ci)
|
||||
ci: (ctx) => `You are the Competitive Intelligence Agent for ${ctx.company.name}.
|
||||
Analyze competitive positioning and peer dynamics.
|
||||
|
||||
**Company:** ${ctx.company.name} (${ctx.company.ticker})
|
||||
**Sector:** ${ctx.company.sector}
|
||||
|
||||
**Instructions:**
|
||||
1. Identify key competitors
|
||||
2. Analyze competitive positioning
|
||||
3. Assess market share trends
|
||||
4. Note recent competitive developments
|
||||
|
||||
**Output Format:**
|
||||
Respond with a JSON object containing:
|
||||
- competitors: Array of key competitors with brief descriptions
|
||||
- marketPosition: Assessment of competitive position
|
||||
- recentMoves: Recent competitive developments
|
||||
- threats: Key competitive threats
|
||||
- opportunities: Opportunities for competitive advantage`,
|
||||
|
||||
// Risk Agent (rk)
|
||||
rk: (ctx) => `You are the Risk Assessment Agent for ${ctx.company.name}.
|
||||
Identify and analyze key investment risks.
|
||||
|
||||
**Memo Sections:**
|
||||
${ctx.memo.sections.map((s) => `## ${s.title}\n${s.content.slice(0, 100)}...`).join("\n\n")}
|
||||
|
||||
**Available Filings:**
|
||||
${ctx.filings.map((f) => `- ${f.formType}: ${f.title}`).join("\n")}
|
||||
|
||||
**Instructions:**
|
||||
1. Identify all material risks
|
||||
2. Categorize by type (business, financial, competitive, regulatory)
|
||||
3. Assess likelihood and impact
|
||||
4. Suggest mitigation strategies
|
||||
|
||||
**Output Format:**
|
||||
Respond with a JSON object containing:
|
||||
- risks: Array of risk descriptions
|
||||
- categories: Risk categories with counts
|
||||
- highPriorityRisks: Risks requiring immediate attention
|
||||
- mitigationStrategies: Suggested mitigations`,
|
||||
|
||||
// Red Team Agent (rt)
|
||||
rt: (ctx) => `You are the Red Team Agent for ${ctx.company.name}.
|
||||
Challenge the investment thesis and identify weaknesses.
|
||||
|
||||
**Current Thesis:**
|
||||
${ctx.memo.sections.find((s) => s.id === "thesis")?.content || "No thesis found"}
|
||||
|
||||
**Instructions:**
|
||||
1. Identify weaknesses in the investment thesis
|
||||
2. Uncover unstated assumptions
|
||||
3. Develop bear case scenarios
|
||||
4. Challenge key projections
|
||||
|
||||
**Output Format:**
|
||||
Respond with a JSON object containing:
|
||||
- counterArguments: Challenges to the thesis
|
||||
- unstatedAssumptions: Hidden assumptions in the analysis
|
||||
- bearCase: Full bear case scenario
|
||||
- redFlags: Warning signs to monitor
|
||||
- recommendation: "proceed_with_caution" | "acceptable" | "needs_revision"`,
|
||||
|
||||
// Monitoring Agent (mn)
|
||||
mn: (ctx) => `You are the Monitoring Agent for ${ctx.company.name}.
|
||||
Set up ongoing monitoring of key indicators.
|
||||
|
||||
**Instructions:**
|
||||
1. Identify key metrics to monitor
|
||||
2. Set alert thresholds
|
||||
3. Define monitoring cadence
|
||||
4. Specify data sources
|
||||
|
||||
**Output Format:**
|
||||
Respond with a JSON object containing:
|
||||
- metricsToMonitor: Key metrics with thresholds
|
||||
- alertConditions: Conditions that should trigger alerts
|
||||
- dataSources: Where to get monitoring data
|
||||
- monitoringCadence: How often to check each metric`,
|
||||
|
||||
// Source Verification Agent (sv)
|
||||
sv: (ctx) => `You are the Source Verification Agent for ${ctx.company.name}.
|
||||
Verify the accuracy of citations and sources in the memo.
|
||||
|
||||
**Memo Citations:**
|
||||
${ctx.memo.sections.length} sections with ${ctx.filings.length} available filings
|
||||
|
||||
**Instructions:**
|
||||
1. Check that all claims are properly cited
|
||||
2. Verify citations are accurate
|
||||
3. Flag uncited assertions
|
||||
4. Assess source quality
|
||||
|
||||
**Output Format:**
|
||||
Respond with a JSON object containing:
|
||||
- verificationResults: Array of citation checks
|
||||
- flaggedClaims: Uncited or poorly supported claims
|
||||
- sourceQuality: Assessment of source quality
|
||||
- recommendations: Improvements for source verification`,
|
||||
|
||||
// Export Agent (ex)
|
||||
ex: (ctx) => `You are the Export Agent for ${ctx.company.name}.
|
||||
Prepare data for export in various formats.
|
||||
|
||||
**Instructions:**
|
||||
1. Format memo for PDF export
|
||||
2. Prepare model for Excel export
|
||||
3. Create presentation outline for PowerPoint
|
||||
4. Ensure data consistency across formats
|
||||
|
||||
**Output Format:**
|
||||
Respond with a JSON object containing:
|
||||
- pdfReady: Memo formatted for PDF
|
||||
- excelReady: Model formatted for Excel
|
||||
- pptReady: Presentation outline
|
||||
- metadata: Export metadata and formatting notes`,
|
||||
|
||||
// Model QA Agent (qa)
|
||||
qa: (ctx) => `You are the Model QA Agent for ${ctx.company.name}.
|
||||
Audit the financial model for errors and inconsistencies.
|
||||
|
||||
**Current Model:**
|
||||
${ctx.model ? JSON.stringify({ headers: ctx.model.headers, rows: ctx.model.rows.slice(0, 20) }, null, 2) : "No model available"}
|
||||
|
||||
**Instructions:**
|
||||
1. Check for formula errors
|
||||
2. Verify balance sheet consistency
|
||||
3. Run sanity checks on projections
|
||||
4. Identify circular references
|
||||
|
||||
**Output Format:**
|
||||
Respond with a JSON object containing:
|
||||
- formulaErrors: Array of formula issues
|
||||
- consistencyIssues: Balance sheet and other consistency problems
|
||||
- sanityCheckResults: Results of sanity checks
|
||||
- recommendations: Suggested fixes and improvements
|
||||
- overallStatus: "pass" | "warn" | "fail"`,
|
||||
|
||||
// Chat Agent (default for agent.chat)
|
||||
chat: (ctx) => `You are a research assistant for ${ctx.company.name} (${ctx.company.ticker}).
|
||||
Help the analyst with questions about the company.
|
||||
|
||||
**Company Info:**
|
||||
- Name: ${ctx.company.name}
|
||||
- Ticker: ${ctx.company.ticker}
|
||||
- Sector: ${ctx.company.sector}
|
||||
|
||||
**User Question:**
|
||||
${ctx.userMessage || "No question provided"}
|
||||
|
||||
**Available Context:**
|
||||
- Memo sections: ${ctx.memo.sections.length}
|
||||
- Filings: ${ctx.filings.length}
|
||||
- Model: ${ctx.model ? "Available" : "Not available"}
|
||||
|
||||
**Instructions:**
|
||||
1. Answer the question based on available data
|
||||
2. Cite your sources when possible
|
||||
3. Admit when you don't have enough information
|
||||
4. Provide clear, concise responses
|
||||
|
||||
Respond in a helpful, professional tone.`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the prompt for a specific agent
|
||||
*/
|
||||
export function getAgentPrompt(agentId: string, context: AgentContext): string {
|
||||
const prompt = AGENT_PROMPTS[agentId] || AGENT_PROMPTS.chat;
|
||||
return prompt(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available agent IDs
|
||||
*/
|
||||
export function getAgentIds(): string[] {
|
||||
return Object.keys(AGENT_PROMPTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent metadata
|
||||
*/
|
||||
export interface AgentMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: "research" | "analysis" | "presentation" | "validation";
|
||||
}
|
||||
|
||||
export const AGENT_METADATA: Record<string, AgentMetadata> = {
|
||||
sf: { id: "sf", name: "SEC Filings", description: "Extracts data from SEC filings", category: "research" },
|
||||
cr: { id: "cr", name: "Company Research", description: "Analyzes business model and competition", category: "research" },
|
||||
fm: { id: "fm", name: "Financial Modeling", description: "Builds financial projections", category: "analysis" },
|
||||
va: { id: "va", name: "Valuation", description: "Provides valuation analysis", category: "analysis" },
|
||||
mw: { id: "mw", name: "Memo Writing", description: "Synthesizes research into memo", category: "presentation" },
|
||||
pa: { id: "pa", name: "Presentation", description: "Creates presentation materials", category: "presentation" },
|
||||
ec: { id: "ec", name: "Earnings Call", description: "Analyzes earnings transcripts", category: "research" },
|
||||
ci: { id: "ci", name: "Competitive Intel", description: "Analyzes competitive position", category: "research" },
|
||||
rk: { id: "rk", name: "Risk", description: "Identifies and assesses risks", category: "analysis" },
|
||||
rt: { id: "rt", name: "Red Team", description: "Challenges investment thesis", category: "validation" },
|
||||
mn: { id: "mn", name: "Monitoring", description: "Sets up monitoring and alerts", category: "analysis" },
|
||||
sv: { id: "sv", name: "Source Verification", description: "Verifies citations and sources", category: "validation" },
|
||||
ex: { id: "ex", name: "Export", description: "Preares data for export", category: "presentation" },
|
||||
qa: { id: "qa", name: "Model QA", description: "Audits financial model", category: "validation" },
|
||||
chat: { id: "chat", name: "Chat", description: "General research assistant", category: "research" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Get metadata for an agent
|
||||
*/
|
||||
export function getAgentMetadata(agentId: string): AgentMetadata | undefined {
|
||||
return AGENT_METADATA[agentId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agents by pipeline
|
||||
*/
|
||||
export function getAgentsByPipeline(pipeline: "research" | "competitive" | "cross-cutting"): string[] {
|
||||
const pipelineMap: Record<string, string[]> = {
|
||||
research: ["sf", "cr", "fm", "va", "mw", "pa"],
|
||||
competitive: ["ec", "ci", "rk", "rt"],
|
||||
"cross-cutting": ["mn", "sv", "ex", "qa"],
|
||||
};
|
||||
return pipelineMap[pipeline] || [];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MemoAnnotation, MemoCitation, MemoSection, MemoSectionReview, RpcMethod, RpcRequestMap, RpcResponseMap, RpcResult } from "../../contracts/src/rpc.js";
|
||||
import { parseRpcRequest, parseRpcResponse } from "../../contracts/src/rpcSchemas.js";
|
||||
import { activeCompanyId, agents, companies, holdings, memoAnnotations, memoCitations, memoSectionReviews, memoSections, modelHeaders, modelRows } from "./demoData.js";
|
||||
import { demoAlerts, demoCatalysts, demoEarnings, demoExports, demoFilings, demoRisks } from "./extraData.js";
|
||||
|
||||
@@ -17,6 +18,7 @@ export async function handleMockRpc<T extends RpcMethod>(
|
||||
payload: RpcRequestMap[T]
|
||||
): Promise<RpcResult<T>> {
|
||||
try {
|
||||
parseRpcRequest(method, payload);
|
||||
switch (method) {
|
||||
case "portfolio.get":
|
||||
return ok("portfolio.get", { id: "core", name: "Core Retail Coverage", holdings, activeCompanyId }) as RpcResult<T>;
|
||||
@@ -197,14 +199,20 @@ export async function handleMockRpc<T extends RpcMethod>(
|
||||
return ok("settings.update", { ok: true }) as RpcResult<T>;
|
||||
|
||||
default:
|
||||
return fail("NOT_FOUND", `Unknown RPC method: ${String(method)}`) as RpcResult<T>;
|
||||
return fail("VALIDATION_ERROR", "Unknown RPC method.") as RpcResult<T>;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "ZodError") {
|
||||
return fail("VALIDATION_ERROR", "Invalid RPC payload.", error) as RpcResult<T>;
|
||||
}
|
||||
return fail("INTERNAL_ERROR", "Unhandled mock RPC failure.", error) as RpcResult<T>;
|
||||
}
|
||||
}
|
||||
|
||||
function ok<T extends RpcMethod>(_: T, data: RpcResponseMap[T]): RpcResult<T> {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
parseRpcResponse(_, data);
|
||||
}
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user