Skip to content

Commit bd21743

Browse files
authored
feat(sentiment): add top sentiment entities query and d-index mapping (#3639)
1 parent fbd8181 commit bd21743

5 files changed

Lines changed: 381 additions & 10 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,3 +715,7 @@ When upgrading Node.js version, update these files:
715715
- This file (`AGENTS.md` - Prerequisites section)
716716

717717
After updating, run `pnpm install` to check if lock file needs updating and commit any changes.
718+
719+
## Sentiment API Contract Notes
720+
721+
- For sentiment time-window selection, use `resolution` consistently across GraphQL args, integration client params, and yggdrasil query-string mapping (`15m`, `1h`, `1d`). Avoid introducing parallel names like `bucket`.

__tests__/schema/sentiment.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ jest.mock('../../src/integrations/yggdrasil/clients', () => ({
1919
yggdrasilSentimentClient: {
2020
getTimeSeries: jest.fn(),
2121
getHighlights: jest.fn(),
22+
getTopEntities: jest.fn(),
2223
},
2324
}));
2425

@@ -30,6 +31,10 @@ const getHighlightsMock =
3031
yggdrasilSentimentClient.getHighlights as jest.MockedFunction<
3132
typeof yggdrasilSentimentClient.getHighlights
3233
>;
34+
const getTopEntitiesMock =
35+
yggdrasilSentimentClient.getTopEntities as jest.MockedFunction<
36+
typeof yggdrasilSentimentClient.getTopEntities
37+
>;
3338

3439
let con: DataSource;
3540
let state: GraphQLTestingState;
@@ -87,6 +92,7 @@ describe('query sentimentTimeSeries', () => {
8792
scores
8893
volume
8994
scoreVariance
95+
dIndex
9096
}
9197
}
9298
}
@@ -103,6 +109,7 @@ describe('query sentimentTimeSeries', () => {
103109
s: [0.5, -0.2],
104110
v: [4, 3],
105111
sv: [0.25, 1],
112+
d: [1.1, 0.8],
106113
},
107114
},
108115
});
@@ -134,6 +141,7 @@ describe('query sentimentTimeSeries', () => {
134141
scores: [0.5, -0.2],
135142
volume: [4, 3],
136143
scoreVariance: [0.25, 1],
144+
dIndex: [1.1, 0.8],
137145
},
138146
],
139147
},
@@ -604,3 +612,208 @@ describe('query sentimentGroup', () => {
604612
});
605613
});
606614
});
615+
616+
describe('query topSentimentEntities', () => {
617+
const QUERY = /* GraphQL */ `
618+
query TopSentimentEntities(
619+
$groupId: ID!
620+
$resolution: SentimentResolution!
621+
$lookback: String
622+
$limit: Int
623+
) {
624+
topSentimentEntities(
625+
groupId: $groupId
626+
resolution: $resolution
627+
lookback: $lookback
628+
limit: $limit
629+
) {
630+
dIndex
631+
score
632+
volume
633+
entity {
634+
entity
635+
name
636+
logo
637+
}
638+
}
639+
}
640+
`;
641+
642+
it('should return top entities with sentiment metadata', async () => {
643+
await con.getRepository(SentimentGroup).insert({
644+
id: '385404b4-f0f4-4e81-a338-bdca851eca31',
645+
name: 'Coding Agents',
646+
});
647+
await con.getRepository(SentimentEntity).insert([
648+
{
649+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
650+
entity: 'cursor',
651+
name: 'Cursor',
652+
logo: 'https://media.daily.dev/image/upload/public/cursor',
653+
},
654+
{
655+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
656+
entity: 'copilot',
657+
name: 'Copilot',
658+
logo: 'https://media.daily.dev/image/upload/public/copilot',
659+
},
660+
]);
661+
662+
getTopEntitiesMock.mockResolvedValue({
663+
entities: [
664+
{ entity: 'cursor', d_index: 12.4, score: 0.7, volume: 20 },
665+
{ entity: 'copilot', d_index: 8.2, score: 0.4, volume: 18 },
666+
],
667+
});
668+
669+
const res = await client.query(QUERY, {
670+
variables: {
671+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
672+
resolution: 'HOUR',
673+
lookback: '24h',
674+
limit: 10,
675+
},
676+
});
677+
678+
expect(res.errors).toBeFalsy();
679+
expect(getTopEntitiesMock).toHaveBeenCalledWith({
680+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
681+
resolution: '1h',
682+
lookback: '24h',
683+
limit: 10,
684+
});
685+
expect(res.data.topSentimentEntities).toEqual([
686+
{
687+
dIndex: 12.4,
688+
score: 0.7,
689+
volume: 20,
690+
entity: {
691+
entity: 'cursor',
692+
name: 'Cursor',
693+
logo: 'https://media.daily.dev/image/upload/public/cursor',
694+
},
695+
},
696+
{
697+
dIndex: 8.2,
698+
score: 0.4,
699+
volume: 18,
700+
entity: {
701+
entity: 'copilot',
702+
name: 'Copilot',
703+
logo: 'https://media.daily.dev/image/upload/public/copilot',
704+
},
705+
},
706+
]);
707+
});
708+
709+
it('should default limit to 20 and filter entities missing from DB', async () => {
710+
await con.getRepository(SentimentGroup).insert({
711+
id: '385404b4-f0f4-4e81-a338-bdca851eca31',
712+
name: 'Coding Agents',
713+
});
714+
await con.getRepository(SentimentEntity).insert({
715+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
716+
entity: 'cursor',
717+
name: 'Cursor',
718+
logo: 'https://media.daily.dev/image/upload/public/cursor',
719+
});
720+
721+
getTopEntitiesMock.mockResolvedValue({
722+
entities: [
723+
{ entity: 'cursor', d_index: 12.4, score: 0.7, volume: 20 },
724+
{ entity: 'missing', d_index: 8.2, score: 0.4, volume: 18 },
725+
],
726+
});
727+
728+
const res = await client.query(QUERY, {
729+
variables: {
730+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
731+
resolution: 'DAY',
732+
},
733+
});
734+
735+
expect(res.errors).toBeFalsy();
736+
expect(getTopEntitiesMock).toHaveBeenCalledWith({
737+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
738+
resolution: '1d',
739+
lookback: undefined,
740+
limit: 20,
741+
});
742+
expect(res.data.topSentimentEntities).toEqual([
743+
{
744+
dIndex: 12.4,
745+
score: 0.7,
746+
volume: 20,
747+
entity: {
748+
entity: 'cursor',
749+
name: 'Cursor',
750+
logo: 'https://media.daily.dev/image/upload/public/cursor',
751+
},
752+
},
753+
]);
754+
});
755+
756+
it('should validate limit range', async () => {
757+
await testQueryErrorCode(
758+
client,
759+
{
760+
query: QUERY,
761+
variables: {
762+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
763+
resolution: 'HOUR',
764+
limit: 0,
765+
},
766+
},
767+
'GRAPHQL_VALIDATION_FAILED',
768+
'limit must be between 1 and 50',
769+
);
770+
});
771+
772+
it('should map 404 to NOT_FOUND', async () => {
773+
getTopEntitiesMock.mockRejectedValue(
774+
new HttpError(
775+
'http://localhost:3002/api/sentiment/top-entities',
776+
404,
777+
'not found',
778+
),
779+
);
780+
781+
await testQueryErrorCode(
782+
client,
783+
{
784+
query: QUERY,
785+
variables: {
786+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
787+
resolution: 'QUARTER_HOUR',
788+
},
789+
},
790+
'NOT_FOUND',
791+
);
792+
});
793+
794+
it('should enforce shared 30/min rate limit for top entities query', async () => {
795+
getTopEntitiesMock.mockResolvedValue({ entities: [] });
796+
797+
for (let attempt = 0; attempt < 30; attempt += 1) {
798+
const response = await client.query(QUERY, {
799+
variables: {
800+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
801+
resolution: 'HOUR',
802+
},
803+
});
804+
expect(response.errors).toBeFalsy();
805+
}
806+
807+
await testQueryErrorCode(
808+
client,
809+
{
810+
query: QUERY,
811+
variables: {
812+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
813+
resolution: 'HOUR',
814+
},
815+
},
816+
'RATE_LIMITED',
817+
);
818+
});
819+
});

src/integrations/yggdrasil/clients.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { retryFetchParse } from '../retry';
55
import type {
66
HighlightsParams,
77
HighlightsResponse,
8+
TopEntitiesParams,
9+
TopEntitiesResponse,
810
TimeSeriesParams,
911
TimeSeriesResponse,
1012
} from './types';
@@ -82,6 +84,30 @@ export class YggdrasilSentimentClient {
8284
),
8385
);
8486
}
87+
88+
getTopEntities(params: TopEntitiesParams): Promise<TopEntitiesResponse> {
89+
const searchParams = new URLSearchParams({
90+
group_id: params.groupId,
91+
resolution: params.resolution,
92+
});
93+
94+
if (params.lookback) {
95+
searchParams.set('lookback', params.lookback);
96+
}
97+
if (params.limit) {
98+
searchParams.set('limit', String(params.limit));
99+
}
100+
101+
return this.garmr.execute(() =>
102+
retryFetchParse<TopEntitiesResponse>(
103+
`${this.url}/api/sentiment/top-entities?${searchParams.toString()}`,
104+
{
105+
...this.fetchOptions,
106+
method: 'GET',
107+
},
108+
),
109+
);
110+
}
85111
}
86112

87113
const yggdrasilSentimentOrigin = process.env.YGGDRASIL_SENTIMENT_ORIGIN;

src/integrations/yggdrasil/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type EntityTimeSeries = {
1010
s: number[];
1111
v: number[];
1212
sv?: number[];
13+
d?: number[];
1314
};
1415

1516
export type TimeSeriesResponse = {
@@ -63,3 +64,21 @@ export type HighlightsResponse = {
6364
items: HighlightItem[];
6465
cursor: string | null;
6566
};
67+
68+
export type TopEntitiesParams = {
69+
groupId: string;
70+
resolution: '15m' | '1h' | '1d';
71+
lookback?: string;
72+
limit?: number;
73+
};
74+
75+
export type TopEntityItem = {
76+
entity: string;
77+
d_index: number;
78+
score: number;
79+
volume: number;
80+
};
81+
82+
export type TopEntitiesResponse = {
83+
entities: TopEntityItem[];
84+
};

0 commit comments

Comments
 (0)