@simonfestl/husky-cli 1.6.2 → 1.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/chat.js +49 -0
- package/dist/commands/workflow.js +5 -2
- package/dist/lib/biz/agent-brain.d.ts +3 -6
- package/dist/lib/biz/agent-brain.js +123 -120
- package/dist/lib/biz/qdrant.d.ts +8 -0
- package/dist/lib/biz/qdrant.js +14 -0
- package/dist/lib/biz/sop.d.ts +98 -0
- package/dist/lib/biz/sop.js +307 -0
- package/package.json +1 -1
package/dist/commands/chat.js
CHANGED
|
@@ -842,6 +842,55 @@ chatCommand
|
|
|
842
842
|
}
|
|
843
843
|
});
|
|
844
844
|
// ============================================
|
|
845
|
+
// GOOGLE CHAT SPACES
|
|
846
|
+
// ============================================
|
|
847
|
+
chatCommand
|
|
848
|
+
.command("spaces")
|
|
849
|
+
.description("List available Google Chat spaces")
|
|
850
|
+
.option("--json", "Output as JSON")
|
|
851
|
+
.action(async (options) => {
|
|
852
|
+
const config = getConfig();
|
|
853
|
+
const huskyApiUrl = getHuskyApiUrl();
|
|
854
|
+
if (!huskyApiUrl) {
|
|
855
|
+
console.error("Error: API URL not configured. Set husky-api-url or api-url.");
|
|
856
|
+
process.exit(1);
|
|
857
|
+
}
|
|
858
|
+
try {
|
|
859
|
+
const res = await fetch(`${huskyApiUrl}/api/google-chat/spaces`, {
|
|
860
|
+
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
861
|
+
});
|
|
862
|
+
if (!res.ok) {
|
|
863
|
+
throw new Error(`API error: ${res.status}`);
|
|
864
|
+
}
|
|
865
|
+
const data = await res.json();
|
|
866
|
+
if (options.json) {
|
|
867
|
+
console.log(JSON.stringify(data, null, 2));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (!data.spaces || data.spaces.length === 0) {
|
|
871
|
+
console.log("No Google Chat spaces found.");
|
|
872
|
+
console.log("Make sure the Husky bot is added to at least one space.");
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
console.log("\n Google Chat Spaces");
|
|
876
|
+
console.log(" " + "─".repeat(60));
|
|
877
|
+
for (const space of data.spaces) {
|
|
878
|
+
const isDefault = space.name === data.defaultSpace ? " (default)" : "";
|
|
879
|
+
const typeIcon = space.type === "SPACE" ? "🏠" : space.type === "GROUP_CHAT" ? "👥" : "💬";
|
|
880
|
+
console.log(` ${typeIcon} ${space.displayName || "(unnamed)"}${isDefault}`);
|
|
881
|
+
console.log(` ID: ${space.name}`);
|
|
882
|
+
console.log("");
|
|
883
|
+
}
|
|
884
|
+
console.log(" Use --space <ID> with chat commands, e.g.:");
|
|
885
|
+
console.log(` husky chat ask --space "${data.spaces[0]?.name}" "Your question"`);
|
|
886
|
+
console.log("");
|
|
887
|
+
}
|
|
888
|
+
catch (error) {
|
|
889
|
+
console.error("Error fetching spaces:", error);
|
|
890
|
+
process.exit(1);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
// ============================================
|
|
845
894
|
// REVIEW COMMANDS (kept for backwards compatibility)
|
|
846
895
|
// ============================================
|
|
847
896
|
chatCommand
|
|
@@ -11,11 +11,11 @@ function ensureConfig() {
|
|
|
11
11
|
}
|
|
12
12
|
return config;
|
|
13
13
|
}
|
|
14
|
-
// husky workflow list
|
|
15
14
|
workflowCommand
|
|
16
15
|
.command("list")
|
|
17
16
|
.description("List all workflows")
|
|
18
17
|
.option("--value-stream <valueStream>", "Filter by value stream")
|
|
18
|
+
.option("-l, --limit <num>", "Max results")
|
|
19
19
|
.option("--json", "Output as JSON")
|
|
20
20
|
.action(async (options) => {
|
|
21
21
|
const config = ensureConfig();
|
|
@@ -30,7 +30,10 @@ workflowCommand
|
|
|
30
30
|
if (!res.ok) {
|
|
31
31
|
throw new Error(`API error: ${res.status}`);
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
let workflows = await res.json();
|
|
34
|
+
if (options.limit) {
|
|
35
|
+
workflows = workflows.slice(0, parseInt(options.limit, 10));
|
|
36
|
+
}
|
|
34
37
|
if (options.json) {
|
|
35
38
|
console.log(JSON.stringify(workflows, null, 2));
|
|
36
39
|
}
|
|
@@ -3,9 +3,9 @@ export type AgentType = typeof AGENT_TYPES[number];
|
|
|
3
3
|
export interface Memory {
|
|
4
4
|
id: string;
|
|
5
5
|
agent: string;
|
|
6
|
+
agentType?: string;
|
|
6
7
|
content: string;
|
|
7
8
|
tags: string[];
|
|
8
|
-
embedding?: number[];
|
|
9
9
|
createdAt: Date;
|
|
10
10
|
updatedAt: Date;
|
|
11
11
|
metadata?: Record<string, unknown>;
|
|
@@ -21,19 +21,17 @@ export interface AgentBrainOptions {
|
|
|
21
21
|
}
|
|
22
22
|
export declare function isValidAgentType(value: string | undefined): value is AgentType;
|
|
23
23
|
export declare function getAgentType(): AgentType | undefined;
|
|
24
|
-
export declare function getDatabaseName(agentType?: AgentType): string;
|
|
25
24
|
export declare class AgentBrain {
|
|
26
|
-
private
|
|
25
|
+
private qdrant;
|
|
27
26
|
private embeddings;
|
|
28
27
|
private agentId;
|
|
29
28
|
private agentType?;
|
|
30
|
-
private collectionPath;
|
|
31
|
-
private databaseName;
|
|
32
29
|
constructor(agentIdOrOptions: string | AgentBrainOptions, projectId?: string);
|
|
33
30
|
getDatabaseInfo(): {
|
|
34
31
|
agentType?: AgentType;
|
|
35
32
|
databaseName: string;
|
|
36
33
|
};
|
|
34
|
+
private ensureCollection;
|
|
37
35
|
remember(content: string, tags?: string[], metadata?: Record<string, unknown>): Promise<string>;
|
|
38
36
|
recall(query: string, limit?: number, minScore?: number): Promise<RecallResult[]>;
|
|
39
37
|
recallByTags(tags: string[], limit?: number): Promise<Memory[]>;
|
|
@@ -43,6 +41,5 @@ export declare class AgentBrain {
|
|
|
43
41
|
count: number;
|
|
44
42
|
tags: Record<string, number>;
|
|
45
43
|
}>;
|
|
46
|
-
private cosineSimilarity;
|
|
47
44
|
}
|
|
48
45
|
export default AgentBrain;
|
|
@@ -1,19 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
|
1
|
+
import { QdrantClient } from './qdrant.js';
|
|
3
2
|
import { EmbeddingService } from './embeddings.js';
|
|
4
3
|
import { getConfig } from '../../commands/config.js';
|
|
5
|
-
|
|
6
|
-
const
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
const MEMORIES_COLLECTION = 'agent-memories';
|
|
6
|
+
const VECTOR_SIZE = 768;
|
|
7
7
|
export const AGENT_TYPES = ['support', 'claude', 'gotess', 'supervisor', 'worker'];
|
|
8
|
-
const AGENT_DB_MAP = {
|
|
9
|
-
support: 'support-brain-db',
|
|
10
|
-
claude: 'claude-brain-db',
|
|
11
|
-
gotess: 'gotess-brain-db',
|
|
12
|
-
supervisor: 'supervisor-brain-db',
|
|
13
|
-
worker: 'worker-brain-db',
|
|
14
|
-
};
|
|
15
|
-
const firestoreCache = new Map();
|
|
16
|
-
let firebaseInitialized = false;
|
|
17
8
|
export function isValidAgentType(value) {
|
|
18
9
|
return value !== undefined && AGENT_TYPES.includes(value);
|
|
19
10
|
}
|
|
@@ -29,19 +20,11 @@ export function getAgentType() {
|
|
|
29
20
|
}
|
|
30
21
|
return undefined;
|
|
31
22
|
}
|
|
32
|
-
export function getDatabaseName(agentType) {
|
|
33
|
-
if (!agentType) {
|
|
34
|
-
return DEFAULT_DATABASE;
|
|
35
|
-
}
|
|
36
|
-
return AGENT_DB_MAP[agentType];
|
|
37
|
-
}
|
|
38
23
|
export class AgentBrain {
|
|
39
|
-
|
|
24
|
+
qdrant;
|
|
40
25
|
embeddings;
|
|
41
26
|
agentId;
|
|
42
27
|
agentType;
|
|
43
|
-
collectionPath;
|
|
44
|
-
databaseName;
|
|
45
28
|
constructor(agentIdOrOptions, projectId) {
|
|
46
29
|
let options;
|
|
47
30
|
if (typeof agentIdOrOptions === 'string') {
|
|
@@ -53,144 +36,164 @@ export class AgentBrain {
|
|
|
53
36
|
const config = getConfig();
|
|
54
37
|
this.agentId = options.agentId;
|
|
55
38
|
this.agentType = options.agentType || getAgentType();
|
|
56
|
-
this.
|
|
39
|
+
this.qdrant = QdrantClient.fromConfig();
|
|
57
40
|
const gcpProject = options.projectId || config.gcpProjectId || process.env.GOOGLE_CLOUD_PROJECT || 'tigerv0';
|
|
58
|
-
if (!firebaseInitialized && getApps().length === 0) {
|
|
59
|
-
initializeApp({ projectId: gcpProject });
|
|
60
|
-
firebaseInitialized = true;
|
|
61
|
-
}
|
|
62
|
-
const cacheKey = `${gcpProject}:${this.databaseName}`;
|
|
63
|
-
let db = firestoreCache.get(cacheKey);
|
|
64
|
-
if (!db) {
|
|
65
|
-
if (this.databaseName === DEFAULT_DATABASE) {
|
|
66
|
-
db = getFirestore();
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
db = getFirestore(this.databaseName);
|
|
70
|
-
}
|
|
71
|
-
firestoreCache.set(cacheKey, db);
|
|
72
|
-
}
|
|
73
|
-
this.db = db;
|
|
74
41
|
this.embeddings = new EmbeddingService({
|
|
75
42
|
projectId: gcpProject,
|
|
76
43
|
location: config.gcpLocation || 'europe-west1'
|
|
77
44
|
});
|
|
78
|
-
this.collectionPath = `${BRAIN_COLLECTION}/${this.agentId}/memories`;
|
|
79
45
|
}
|
|
80
46
|
getDatabaseInfo() {
|
|
81
47
|
return {
|
|
82
48
|
agentType: this.agentType,
|
|
83
|
-
databaseName:
|
|
49
|
+
databaseName: `qdrant:${MEMORIES_COLLECTION}`,
|
|
84
50
|
};
|
|
85
51
|
}
|
|
52
|
+
async ensureCollection() {
|
|
53
|
+
try {
|
|
54
|
+
await this.qdrant.getCollection(MEMORIES_COLLECTION);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
await this.qdrant.createCollection(MEMORIES_COLLECTION, VECTOR_SIZE);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
86
60
|
async remember(content, tags = [], metadata) {
|
|
61
|
+
await this.ensureCollection();
|
|
87
62
|
const embedding = await this.embeddings.embed(content);
|
|
88
|
-
const
|
|
63
|
+
const id = randomUUID();
|
|
64
|
+
const now = new Date().toISOString();
|
|
65
|
+
await this.qdrant.upsertOne(MEMORIES_COLLECTION, id, embedding, {
|
|
89
66
|
agent: this.agentId,
|
|
67
|
+
agentType: this.agentType || 'default',
|
|
90
68
|
content,
|
|
91
69
|
tags,
|
|
92
|
-
embedding,
|
|
93
70
|
metadata: metadata || {},
|
|
94
|
-
createdAt:
|
|
95
|
-
updatedAt:
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
return ref.id;
|
|
71
|
+
createdAt: now,
|
|
72
|
+
updatedAt: now,
|
|
73
|
+
});
|
|
74
|
+
return id;
|
|
99
75
|
}
|
|
100
76
|
async recall(query, limit = 5, minScore = 0.5) {
|
|
77
|
+
await this.ensureCollection();
|
|
101
78
|
const queryEmbedding = await this.embeddings.embed(query);
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (score >= minScore) {
|
|
110
|
-
results.push({
|
|
111
|
-
memory: {
|
|
112
|
-
id: doc.id,
|
|
113
|
-
agent: data.agent,
|
|
114
|
-
content: data.content,
|
|
115
|
-
tags: data.tags || [],
|
|
116
|
-
createdAt: data.createdAt?.toDate() || new Date(),
|
|
117
|
-
updatedAt: data.updatedAt?.toDate() || new Date(),
|
|
118
|
-
metadata: data.metadata,
|
|
119
|
-
},
|
|
120
|
-
score,
|
|
121
|
-
});
|
|
122
|
-
}
|
|
79
|
+
const filter = {
|
|
80
|
+
must: [
|
|
81
|
+
{ key: 'agent', match: { value: this.agentId } }
|
|
82
|
+
]
|
|
83
|
+
};
|
|
84
|
+
if (this.agentType) {
|
|
85
|
+
filter.must.push({ key: 'agentType', match: { value: this.agentType } });
|
|
123
86
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
87
|
+
const results = await this.qdrant.search(MEMORIES_COLLECTION, queryEmbedding, limit, {
|
|
88
|
+
filter,
|
|
89
|
+
scoreThreshold: minScore,
|
|
90
|
+
});
|
|
91
|
+
return results.map(r => ({
|
|
92
|
+
memory: {
|
|
93
|
+
id: String(r.id),
|
|
94
|
+
agent: String(r.payload?.agent || ''),
|
|
95
|
+
agentType: String(r.payload?.agentType || ''),
|
|
96
|
+
content: String(r.payload?.content || ''),
|
|
97
|
+
tags: r.payload?.tags || [],
|
|
98
|
+
createdAt: new Date(String(r.payload?.createdAt || new Date().toISOString())),
|
|
99
|
+
updatedAt: new Date(String(r.payload?.updatedAt || new Date().toISOString())),
|
|
100
|
+
metadata: r.payload?.metadata,
|
|
101
|
+
},
|
|
102
|
+
score: r.score,
|
|
103
|
+
}));
|
|
127
104
|
}
|
|
128
105
|
async recallByTags(tags, limit = 10) {
|
|
129
106
|
if (tags.length === 0) {
|
|
130
107
|
return [];
|
|
131
108
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
109
|
+
await this.ensureCollection();
|
|
110
|
+
const filter = {
|
|
111
|
+
must: [
|
|
112
|
+
{ key: 'agent', match: { value: this.agentId } },
|
|
113
|
+
{
|
|
114
|
+
should: tags.map(tag => ({
|
|
115
|
+
key: 'tags',
|
|
116
|
+
match: { any: [tag] }
|
|
117
|
+
}))
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
};
|
|
121
|
+
const results = await this.qdrant.scroll(MEMORIES_COLLECTION, {
|
|
122
|
+
filter,
|
|
123
|
+
limit,
|
|
124
|
+
with_payload: true,
|
|
125
|
+
});
|
|
126
|
+
return results
|
|
127
|
+
.map(r => ({
|
|
128
|
+
id: String(r.id),
|
|
129
|
+
agent: String(r.payload?.agent || ''),
|
|
130
|
+
agentType: String(r.payload?.agentType || ''),
|
|
131
|
+
content: String(r.payload?.content || ''),
|
|
132
|
+
tags: r.payload?.tags || [],
|
|
133
|
+
createdAt: new Date(String(r.payload?.createdAt || new Date().toISOString())),
|
|
134
|
+
updatedAt: new Date(String(r.payload?.updatedAt || new Date().toISOString())),
|
|
135
|
+
metadata: r.payload?.metadata,
|
|
136
|
+
}))
|
|
137
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
147
138
|
}
|
|
148
139
|
async forget(memoryId) {
|
|
149
|
-
await this.
|
|
140
|
+
await this.ensureCollection();
|
|
141
|
+
await this.qdrant.deletePoints(MEMORIES_COLLECTION, [memoryId]);
|
|
150
142
|
}
|
|
151
143
|
async listMemories(limit = 20) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
})
|
|
144
|
+
await this.ensureCollection();
|
|
145
|
+
const filter = {
|
|
146
|
+
must: [
|
|
147
|
+
{ key: 'agent', match: { value: this.agentId } }
|
|
148
|
+
]
|
|
149
|
+
};
|
|
150
|
+
if (this.agentType) {
|
|
151
|
+
filter.must.push({ key: 'agentType', match: { value: this.agentType } });
|
|
152
|
+
}
|
|
153
|
+
const results = await this.qdrant.scroll(MEMORIES_COLLECTION, {
|
|
154
|
+
filter,
|
|
155
|
+
limit,
|
|
156
|
+
with_payload: true,
|
|
157
|
+
});
|
|
158
|
+
return results
|
|
159
|
+
.map(r => ({
|
|
160
|
+
id: String(r.id),
|
|
161
|
+
agent: String(r.payload?.agent || ''),
|
|
162
|
+
agentType: String(r.payload?.agentType || ''),
|
|
163
|
+
content: String(r.payload?.content || ''),
|
|
164
|
+
tags: r.payload?.tags || [],
|
|
165
|
+
createdAt: new Date(String(r.payload?.createdAt || new Date().toISOString())),
|
|
166
|
+
updatedAt: new Date(String(r.payload?.updatedAt || new Date().toISOString())),
|
|
167
|
+
metadata: r.payload?.metadata,
|
|
168
|
+
}))
|
|
169
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
166
170
|
}
|
|
167
171
|
async stats() {
|
|
168
|
-
|
|
172
|
+
await this.ensureCollection();
|
|
173
|
+
const filter = {
|
|
174
|
+
must: [
|
|
175
|
+
{ key: 'agent', match: { value: this.agentId } }
|
|
176
|
+
]
|
|
177
|
+
};
|
|
178
|
+
if (this.agentType) {
|
|
179
|
+
filter.must.push({ key: 'agentType', match: { value: this.agentType } });
|
|
180
|
+
}
|
|
181
|
+
const results = await this.qdrant.scroll(MEMORIES_COLLECTION, {
|
|
182
|
+
filter,
|
|
183
|
+
limit: 1000,
|
|
184
|
+
with_payload: true,
|
|
185
|
+
});
|
|
169
186
|
const tagCounts = {};
|
|
170
|
-
for (const
|
|
171
|
-
const tags =
|
|
187
|
+
for (const r of results) {
|
|
188
|
+
const tags = r.payload?.tags || [];
|
|
172
189
|
for (const tag of tags) {
|
|
173
190
|
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
|
174
191
|
}
|
|
175
192
|
}
|
|
176
193
|
return {
|
|
177
|
-
count:
|
|
194
|
+
count: results.length,
|
|
178
195
|
tags: tagCounts,
|
|
179
196
|
};
|
|
180
197
|
}
|
|
181
|
-
cosineSimilarity(a, b) {
|
|
182
|
-
if (a.length !== b.length)
|
|
183
|
-
return 0;
|
|
184
|
-
let dotProduct = 0;
|
|
185
|
-
let normA = 0;
|
|
186
|
-
let normB = 0;
|
|
187
|
-
for (let i = 0; i < a.length; i++) {
|
|
188
|
-
dotProduct += a[i] * b[i];
|
|
189
|
-
normA += a[i] * a[i];
|
|
190
|
-
normB += b[i] * b[i];
|
|
191
|
-
}
|
|
192
|
-
const magnitude = Math.sqrt(normA) * Math.sqrt(normB);
|
|
193
|
-
return magnitude === 0 ? 0 : dotProduct / magnitude;
|
|
194
|
-
}
|
|
195
198
|
}
|
|
196
199
|
export default AgentBrain;
|
package/dist/lib/biz/qdrant.d.ts
CHANGED
|
@@ -49,5 +49,13 @@ export declare class QdrantClient {
|
|
|
49
49
|
getPoint(collectionName: string, id: string | number): Promise<Point | null>;
|
|
50
50
|
deletePoints(collectionName: string, ids: (string | number)[]): Promise<void>;
|
|
51
51
|
count(collectionName: string): Promise<number>;
|
|
52
|
+
scroll(collectionName: string, options?: {
|
|
53
|
+
filter?: Record<string, unknown>;
|
|
54
|
+
limit?: number;
|
|
55
|
+
with_payload?: boolean;
|
|
56
|
+
}): Promise<Array<{
|
|
57
|
+
id: string | number;
|
|
58
|
+
payload: Record<string, unknown>;
|
|
59
|
+
}>>;
|
|
52
60
|
}
|
|
53
61
|
export default QdrantClient;
|
package/dist/lib/biz/qdrant.js
CHANGED
|
@@ -157,5 +157,19 @@ export class QdrantClient {
|
|
|
157
157
|
const info = await this.getCollection(collectionName);
|
|
158
158
|
return info.pointsCount;
|
|
159
159
|
}
|
|
160
|
+
async scroll(collectionName, options = {}) {
|
|
161
|
+
const response = await this.request(`/collections/${collectionName}/points/scroll`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
filter: options.filter,
|
|
165
|
+
limit: options.limit || 50,
|
|
166
|
+
with_payload: options.with_payload ?? true,
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
return response.result.points.map(p => ({
|
|
170
|
+
id: p.id,
|
|
171
|
+
payload: p.payload || {},
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
160
174
|
}
|
|
161
175
|
export default QdrantClient;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SOP (Standard Operating Procedure) Service
|
|
3
|
+
*
|
|
4
|
+
* Manages SOPs in Qdrant with semantic search capabilities.
|
|
5
|
+
* SOPs are auto-generated from resolved ticket patterns and can be
|
|
6
|
+
* approved/deprecated by humans.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SOP_STATUSES: readonly ["draft", "approved", "deprecated"];
|
|
9
|
+
export type SOPStatus = typeof SOP_STATUSES[number];
|
|
10
|
+
export interface SOPStep {
|
|
11
|
+
order: number;
|
|
12
|
+
action: string;
|
|
13
|
+
description: string;
|
|
14
|
+
tool?: string;
|
|
15
|
+
required?: boolean;
|
|
16
|
+
conditions?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SOP {
|
|
19
|
+
id: string;
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
trigger: string;
|
|
23
|
+
category: string;
|
|
24
|
+
steps: SOPStep[];
|
|
25
|
+
source_tickets: number[];
|
|
26
|
+
status: SOPStatus;
|
|
27
|
+
tags: string[];
|
|
28
|
+
created_at: string;
|
|
29
|
+
updated_at: string;
|
|
30
|
+
approved_by?: string;
|
|
31
|
+
approved_at?: string;
|
|
32
|
+
deprecated_by?: string;
|
|
33
|
+
deprecated_at?: string;
|
|
34
|
+
version: number;
|
|
35
|
+
confidence_score?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface CreateSOPInput {
|
|
38
|
+
title: string;
|
|
39
|
+
description: string;
|
|
40
|
+
trigger: string;
|
|
41
|
+
category: string;
|
|
42
|
+
steps: SOPStep[];
|
|
43
|
+
source_tickets?: number[];
|
|
44
|
+
tags?: string[];
|
|
45
|
+
confidence_score?: number;
|
|
46
|
+
}
|
|
47
|
+
export interface UpdateSOPInput {
|
|
48
|
+
title?: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
trigger?: string;
|
|
51
|
+
category?: string;
|
|
52
|
+
steps?: SOPStep[];
|
|
53
|
+
tags?: string[];
|
|
54
|
+
}
|
|
55
|
+
export interface SOPSearchResult {
|
|
56
|
+
sop: SOP;
|
|
57
|
+
score: number;
|
|
58
|
+
}
|
|
59
|
+
export declare class SOPService {
|
|
60
|
+
private qdrant;
|
|
61
|
+
private embeddings;
|
|
62
|
+
constructor();
|
|
63
|
+
create(input: CreateSOPInput): Promise<SOP>;
|
|
64
|
+
get(id: string): Promise<SOP | null>;
|
|
65
|
+
update(id: string, input: UpdateSOPInput): Promise<SOP | null>;
|
|
66
|
+
approve(id: string, approver: string): Promise<SOP | null>;
|
|
67
|
+
deprecate(id: string, deprecator: string): Promise<SOP | null>;
|
|
68
|
+
delete(id: string): Promise<void>;
|
|
69
|
+
list(options?: {
|
|
70
|
+
status?: SOPStatus;
|
|
71
|
+
category?: string;
|
|
72
|
+
limit?: number;
|
|
73
|
+
}): Promise<SOP[]>;
|
|
74
|
+
search(query: string, options?: {
|
|
75
|
+
limit?: number;
|
|
76
|
+
status?: SOPStatus;
|
|
77
|
+
minScore?: number;
|
|
78
|
+
}): Promise<SOPSearchResult[]>;
|
|
79
|
+
findByTrigger(trigger: string, options?: {
|
|
80
|
+
status?: SOPStatus;
|
|
81
|
+
minScore?: number;
|
|
82
|
+
}): Promise<SOPSearchResult | null>;
|
|
83
|
+
getByCategory(category: string, status?: SOPStatus): Promise<SOP[]>;
|
|
84
|
+
getCategories(): Promise<{
|
|
85
|
+
category: string;
|
|
86
|
+
count: number;
|
|
87
|
+
}[]>;
|
|
88
|
+
stats(): Promise<{
|
|
89
|
+
total: number;
|
|
90
|
+
byStatus: Record<SOPStatus, number>;
|
|
91
|
+
byCategory: Record<string, number>;
|
|
92
|
+
}>;
|
|
93
|
+
private buildEmbeddingText;
|
|
94
|
+
private sopToPayload;
|
|
95
|
+
private payloadToSOP;
|
|
96
|
+
private scrollPoints;
|
|
97
|
+
}
|
|
98
|
+
export default SOPService;
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SOP (Standard Operating Procedure) Service
|
|
3
|
+
*
|
|
4
|
+
* Manages SOPs in Qdrant with semantic search capabilities.
|
|
5
|
+
* SOPs are auto-generated from resolved ticket patterns and can be
|
|
6
|
+
* approved/deprecated by humans.
|
|
7
|
+
*/
|
|
8
|
+
import { QdrantClient } from './qdrant.js';
|
|
9
|
+
import { EmbeddingService } from './embeddings.js';
|
|
10
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Types
|
|
13
|
+
// ============================================================================
|
|
14
|
+
export const SOP_STATUSES = ['draft', 'approved', 'deprecated'];
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Constants
|
|
17
|
+
// ============================================================================
|
|
18
|
+
const SOP_COLLECTION = 'knowledge';
|
|
19
|
+
const VECTOR_DIM = 768;
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// SOP Service
|
|
22
|
+
// ============================================================================
|
|
23
|
+
export class SOPService {
|
|
24
|
+
qdrant;
|
|
25
|
+
embeddings;
|
|
26
|
+
constructor() {
|
|
27
|
+
this.qdrant = QdrantClient.fromConfig();
|
|
28
|
+
this.embeddings = EmbeddingService.fromConfig();
|
|
29
|
+
}
|
|
30
|
+
async create(input) {
|
|
31
|
+
const id = `sop_${uuidv4().slice(0, 8)}`;
|
|
32
|
+
const now = new Date().toISOString();
|
|
33
|
+
const sop = {
|
|
34
|
+
id,
|
|
35
|
+
title: input.title,
|
|
36
|
+
description: input.description,
|
|
37
|
+
trigger: input.trigger,
|
|
38
|
+
category: input.category,
|
|
39
|
+
steps: input.steps.map((s, i) => ({
|
|
40
|
+
...s,
|
|
41
|
+
order: s.order || i + 1,
|
|
42
|
+
required: s.required ?? true,
|
|
43
|
+
})),
|
|
44
|
+
source_tickets: input.source_tickets || [],
|
|
45
|
+
status: 'draft',
|
|
46
|
+
tags: input.tags || [],
|
|
47
|
+
created_at: now,
|
|
48
|
+
updated_at: now,
|
|
49
|
+
version: 1,
|
|
50
|
+
confidence_score: input.confidence_score,
|
|
51
|
+
};
|
|
52
|
+
const embeddingText = this.buildEmbeddingText(sop);
|
|
53
|
+
const vector = await this.embeddings.embed(embeddingText);
|
|
54
|
+
await this.qdrant.upsertOne(SOP_COLLECTION, id, vector, {
|
|
55
|
+
type: 'sop',
|
|
56
|
+
...this.sopToPayload(sop),
|
|
57
|
+
});
|
|
58
|
+
return sop;
|
|
59
|
+
}
|
|
60
|
+
async get(id) {
|
|
61
|
+
const point = await this.qdrant.getPoint(SOP_COLLECTION, id);
|
|
62
|
+
if (!point || !point.payload)
|
|
63
|
+
return null;
|
|
64
|
+
const payload = point.payload;
|
|
65
|
+
if (payload.type !== 'sop')
|
|
66
|
+
return null;
|
|
67
|
+
return this.payloadToSOP(payload);
|
|
68
|
+
}
|
|
69
|
+
async update(id, input) {
|
|
70
|
+
const existing = await this.get(id);
|
|
71
|
+
if (!existing)
|
|
72
|
+
return null;
|
|
73
|
+
const now = new Date().toISOString();
|
|
74
|
+
const updated = {
|
|
75
|
+
...existing,
|
|
76
|
+
title: input.title ?? existing.title,
|
|
77
|
+
description: input.description ?? existing.description,
|
|
78
|
+
trigger: input.trigger ?? existing.trigger,
|
|
79
|
+
category: input.category ?? existing.category,
|
|
80
|
+
steps: input.steps ?? existing.steps,
|
|
81
|
+
tags: input.tags ?? existing.tags,
|
|
82
|
+
updated_at: now,
|
|
83
|
+
version: existing.version + 1,
|
|
84
|
+
};
|
|
85
|
+
const embeddingText = this.buildEmbeddingText(updated);
|
|
86
|
+
const vector = await this.embeddings.embed(embeddingText);
|
|
87
|
+
await this.qdrant.upsertOne(SOP_COLLECTION, id, vector, {
|
|
88
|
+
type: 'sop',
|
|
89
|
+
...this.sopToPayload(updated),
|
|
90
|
+
});
|
|
91
|
+
return updated;
|
|
92
|
+
}
|
|
93
|
+
async approve(id, approver) {
|
|
94
|
+
const existing = await this.get(id);
|
|
95
|
+
if (!existing)
|
|
96
|
+
return null;
|
|
97
|
+
if (existing.status !== 'draft') {
|
|
98
|
+
throw new Error(`Cannot approve SOP with status '${existing.status}'. Only draft SOPs can be approved.`);
|
|
99
|
+
}
|
|
100
|
+
const now = new Date().toISOString();
|
|
101
|
+
const updated = {
|
|
102
|
+
...existing,
|
|
103
|
+
status: 'approved',
|
|
104
|
+
approved_by: approver,
|
|
105
|
+
approved_at: now,
|
|
106
|
+
updated_at: now,
|
|
107
|
+
};
|
|
108
|
+
const embeddingText = this.buildEmbeddingText(updated);
|
|
109
|
+
const vector = await this.embeddings.embed(embeddingText);
|
|
110
|
+
await this.qdrant.upsertOne(SOP_COLLECTION, id, vector, {
|
|
111
|
+
type: 'sop',
|
|
112
|
+
...this.sopToPayload(updated),
|
|
113
|
+
});
|
|
114
|
+
return updated;
|
|
115
|
+
}
|
|
116
|
+
async deprecate(id, deprecator) {
|
|
117
|
+
const existing = await this.get(id);
|
|
118
|
+
if (!existing)
|
|
119
|
+
return null;
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
const updated = {
|
|
122
|
+
...existing,
|
|
123
|
+
status: 'deprecated',
|
|
124
|
+
deprecated_by: deprecator,
|
|
125
|
+
deprecated_at: now,
|
|
126
|
+
updated_at: now,
|
|
127
|
+
};
|
|
128
|
+
const embeddingText = this.buildEmbeddingText(updated);
|
|
129
|
+
const vector = await this.embeddings.embed(embeddingText);
|
|
130
|
+
await this.qdrant.upsertOne(SOP_COLLECTION, id, vector, {
|
|
131
|
+
type: 'sop',
|
|
132
|
+
...this.sopToPayload(updated),
|
|
133
|
+
});
|
|
134
|
+
return updated;
|
|
135
|
+
}
|
|
136
|
+
async delete(id) {
|
|
137
|
+
await this.qdrant.deletePoints(SOP_COLLECTION, [id]);
|
|
138
|
+
}
|
|
139
|
+
async list(options = {}) {
|
|
140
|
+
const limit = options.limit || 50;
|
|
141
|
+
const mustConditions = [
|
|
142
|
+
{ key: 'type', match: { value: 'sop' } }
|
|
143
|
+
];
|
|
144
|
+
if (options.status) {
|
|
145
|
+
mustConditions.push({ key: 'status', match: { value: options.status } });
|
|
146
|
+
}
|
|
147
|
+
if (options.category) {
|
|
148
|
+
mustConditions.push({ key: 'category', match: { value: options.category } });
|
|
149
|
+
}
|
|
150
|
+
const response = await this.scrollPoints(SOP_COLLECTION, {
|
|
151
|
+
filter: { must: mustConditions },
|
|
152
|
+
limit,
|
|
153
|
+
with_payload: true,
|
|
154
|
+
});
|
|
155
|
+
return response
|
|
156
|
+
.map(p => this.payloadToSOP(p.payload))
|
|
157
|
+
.filter((sop) => sop !== null);
|
|
158
|
+
}
|
|
159
|
+
async search(query, options = {}) {
|
|
160
|
+
const limit = options.limit || 5;
|
|
161
|
+
const minScore = options.minScore || 0.5;
|
|
162
|
+
const vector = await this.embeddings.embed(query);
|
|
163
|
+
const mustConditions = [
|
|
164
|
+
{ key: 'type', match: { value: 'sop' } }
|
|
165
|
+
];
|
|
166
|
+
if (options.status) {
|
|
167
|
+
mustConditions.push({ key: 'status', match: { value: options.status } });
|
|
168
|
+
}
|
|
169
|
+
const results = await this.qdrant.search(SOP_COLLECTION, vector, limit, {
|
|
170
|
+
filter: { must: mustConditions },
|
|
171
|
+
scoreThreshold: minScore,
|
|
172
|
+
});
|
|
173
|
+
return results
|
|
174
|
+
.map(r => {
|
|
175
|
+
const sop = this.payloadToSOP(r.payload);
|
|
176
|
+
if (!sop)
|
|
177
|
+
return null;
|
|
178
|
+
return { sop, score: r.score };
|
|
179
|
+
})
|
|
180
|
+
.filter((r) => r !== null);
|
|
181
|
+
}
|
|
182
|
+
async findByTrigger(trigger, options = {}) {
|
|
183
|
+
const results = await this.search(trigger, {
|
|
184
|
+
limit: 1,
|
|
185
|
+
status: options.status || 'approved',
|
|
186
|
+
minScore: options.minScore || 0.7,
|
|
187
|
+
});
|
|
188
|
+
return results[0] || null;
|
|
189
|
+
}
|
|
190
|
+
async getByCategory(category, status) {
|
|
191
|
+
return this.list({ category, status });
|
|
192
|
+
}
|
|
193
|
+
async getCategories() {
|
|
194
|
+
const sops = await this.list();
|
|
195
|
+
const categoryMap = new Map();
|
|
196
|
+
for (const sop of sops) {
|
|
197
|
+
const count = categoryMap.get(sop.category) || 0;
|
|
198
|
+
categoryMap.set(sop.category, count + 1);
|
|
199
|
+
}
|
|
200
|
+
return Array.from(categoryMap.entries())
|
|
201
|
+
.map(([category, count]) => ({ category, count }))
|
|
202
|
+
.sort((a, b) => b.count - a.count);
|
|
203
|
+
}
|
|
204
|
+
async stats() {
|
|
205
|
+
const sops = await this.list({ limit: 1000 });
|
|
206
|
+
const byStatus = {
|
|
207
|
+
draft: 0,
|
|
208
|
+
approved: 0,
|
|
209
|
+
deprecated: 0,
|
|
210
|
+
};
|
|
211
|
+
const byCategory = {};
|
|
212
|
+
for (const sop of sops) {
|
|
213
|
+
byStatus[sop.status]++;
|
|
214
|
+
byCategory[sop.category] = (byCategory[sop.category] || 0) + 1;
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
total: sops.length,
|
|
218
|
+
byStatus,
|
|
219
|
+
byCategory,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
buildEmbeddingText(sop) {
|
|
223
|
+
const stepsText = sop.steps
|
|
224
|
+
.map(s => `Step ${s.order}: ${s.action} - ${s.description}`)
|
|
225
|
+
.join('\n');
|
|
226
|
+
return `${sop.title}\n${sop.description}\nTrigger: ${sop.trigger}\nCategory: ${sop.category}\n${stepsText}`;
|
|
227
|
+
}
|
|
228
|
+
sopToPayload(sop) {
|
|
229
|
+
return {
|
|
230
|
+
id: sop.id,
|
|
231
|
+
title: sop.title,
|
|
232
|
+
description: sop.description,
|
|
233
|
+
trigger: sop.trigger,
|
|
234
|
+
category: sop.category,
|
|
235
|
+
steps: JSON.stringify(sop.steps),
|
|
236
|
+
source_tickets: sop.source_tickets,
|
|
237
|
+
status: sop.status,
|
|
238
|
+
tags: sop.tags,
|
|
239
|
+
created_at: sop.created_at,
|
|
240
|
+
updated_at: sop.updated_at,
|
|
241
|
+
approved_by: sop.approved_by || null,
|
|
242
|
+
approved_at: sop.approved_at || null,
|
|
243
|
+
deprecated_by: sop.deprecated_by || null,
|
|
244
|
+
deprecated_at: sop.deprecated_at || null,
|
|
245
|
+
version: sop.version,
|
|
246
|
+
confidence_score: sop.confidence_score || null,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
payloadToSOP(payload) {
|
|
250
|
+
if (!payload || payload.type !== 'sop')
|
|
251
|
+
return null;
|
|
252
|
+
let steps = [];
|
|
253
|
+
try {
|
|
254
|
+
if (typeof payload.steps === 'string') {
|
|
255
|
+
steps = JSON.parse(payload.steps);
|
|
256
|
+
}
|
|
257
|
+
else if (Array.isArray(payload.steps)) {
|
|
258
|
+
steps = payload.steps;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
steps = [];
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
id: String(payload.id),
|
|
266
|
+
title: String(payload.title || ''),
|
|
267
|
+
description: String(payload.description || ''),
|
|
268
|
+
trigger: String(payload.trigger || ''),
|
|
269
|
+
category: String(payload.category || 'general'),
|
|
270
|
+
steps,
|
|
271
|
+
source_tickets: payload.source_tickets || [],
|
|
272
|
+
status: payload.status || 'draft',
|
|
273
|
+
tags: payload.tags || [],
|
|
274
|
+
created_at: String(payload.created_at || new Date().toISOString()),
|
|
275
|
+
updated_at: String(payload.updated_at || new Date().toISOString()),
|
|
276
|
+
approved_by: payload.approved_by ? String(payload.approved_by) : undefined,
|
|
277
|
+
approved_at: payload.approved_at ? String(payload.approved_at) : undefined,
|
|
278
|
+
deprecated_by: payload.deprecated_by ? String(payload.deprecated_by) : undefined,
|
|
279
|
+
deprecated_at: payload.deprecated_at ? String(payload.deprecated_at) : undefined,
|
|
280
|
+
version: Number(payload.version) || 1,
|
|
281
|
+
confidence_score: payload.confidence_score ? Number(payload.confidence_score) : undefined,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
async scrollPoints(collection, options) {
|
|
285
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
286
|
+
const qdrantAny = this.qdrant;
|
|
287
|
+
try {
|
|
288
|
+
const response = await qdrantAny.request(`/collections/${collection}/points/scroll`, {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
body: JSON.stringify({
|
|
291
|
+
filter: options.filter,
|
|
292
|
+
limit: options.limit || 50,
|
|
293
|
+
with_payload: options.with_payload ?? true,
|
|
294
|
+
}),
|
|
295
|
+
});
|
|
296
|
+
return response.result.points.map((p) => ({
|
|
297
|
+
id: p.id,
|
|
298
|
+
payload: p.payload || {},
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
console.error('Scroll failed:', error);
|
|
303
|
+
return [];
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
export default SOPService;
|