feat: User interviews tool (#32237)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 109 KiB |
@@ -102,11 +102,6 @@ await buildInParallel(
|
||||
deniedPatterns.some((pattern) => pattern.test(args.path))
|
||||
|
||||
if (shouldDeny) {
|
||||
console.log(
|
||||
'replacing',
|
||||
args.path,
|
||||
'with empty module. it is not allowed in the toolbar bundle.'
|
||||
)
|
||||
return {
|
||||
path: args.path,
|
||||
namespace: 'empty-module',
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"@posthog/products-dashboards": "workspace:*",
|
||||
"@posthog/products-early-access-features": "workspace:*",
|
||||
"@posthog/products-experiments": "workspace:*",
|
||||
"@posthog/products-user-interviews": "workspace:*",
|
||||
"@posthog/products-feature-flags": "workspace:*",
|
||||
"@posthog/products-games": "workspace:*",
|
||||
"@posthog/products-groups": "workspace:*",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
IconAI,
|
||||
IconArrowUpRight,
|
||||
IconChat,
|
||||
IconCursorClick,
|
||||
IconDashboard,
|
||||
IconDatabase,
|
||||
@@ -624,6 +625,14 @@ export const navigation3000Logic = kea<navigation3000LogicType>([
|
||||
to: urls.earlyAccessFeatures(),
|
||||
tooltipDocLink: 'https://posthog.com/docs/feature-flags/early-access-feature-management',
|
||||
},
|
||||
featureFlags[FEATURE_FLAGS.USER_INTERVIEWS]
|
||||
? {
|
||||
identifier: Scene.UserInterviews,
|
||||
label: 'User interviews',
|
||||
icon: <IconChat />,
|
||||
to: urls.userInterviews(),
|
||||
}
|
||||
: null,
|
||||
featureFlags[FEATURE_FLAGS.LLM_OBSERVABILITY]
|
||||
? {
|
||||
identifier: 'LLMObservability',
|
||||
|
||||
@@ -137,6 +137,7 @@ import {
|
||||
TeamType,
|
||||
UserBasicType,
|
||||
UserGroup,
|
||||
UserInterviewType,
|
||||
UserType,
|
||||
} from '~/types'
|
||||
|
||||
@@ -808,6 +809,15 @@ class ApiRequest {
|
||||
return this.earlyAccessFeatures(teamId).addPathComponent(id)
|
||||
}
|
||||
|
||||
// # User interviews
|
||||
public userInterviews(teamId?: TeamType['id']): ApiRequest {
|
||||
return this.environmentsDetail(teamId).addPathComponent('user_interviews')
|
||||
}
|
||||
|
||||
public userInterview(id: UserInterviewType['id'], teamId?: TeamType['id']): ApiRequest {
|
||||
return this.userInterviews(teamId).addPathComponent(id)
|
||||
}
|
||||
|
||||
// # Surveys
|
||||
public surveys(teamId?: TeamType['id']): ApiRequest {
|
||||
return this.projectsDetail(teamId).addPathComponent('surveys')
|
||||
@@ -2788,6 +2798,21 @@ const api = {
|
||||
},
|
||||
},
|
||||
|
||||
userInterviews: {
|
||||
async list(): Promise<PaginatedResponse<UserInterviewType>> {
|
||||
return await new ApiRequest().userInterviews().get()
|
||||
},
|
||||
async get(id: UserInterviewType['id']): Promise<UserInterviewType> {
|
||||
return await new ApiRequest().userInterview(id).get()
|
||||
},
|
||||
async update(
|
||||
id: UserInterviewType['id'],
|
||||
data: Pick<UserInterviewType, 'summary'>
|
||||
): Promise<UserInterviewType> {
|
||||
return await new ApiRequest().userInterview(id).update({ data })
|
||||
},
|
||||
},
|
||||
|
||||
surveys: {
|
||||
async list(
|
||||
args: {
|
||||
|
||||
@@ -264,6 +264,7 @@ export const FEATURE_FLAGS = {
|
||||
GET_HOG_TEMPLATES_FROM_DB: 'get-hog-templates-from-db', // owner: @meikel #team-cdp
|
||||
LINK: 'link', // owner: @marconlp #team-link
|
||||
GAME_CENTER: 'game-center', // owner: everybody
|
||||
USER_INTERVIEWS: 'user-interviews', // owner: @Twixes @jurajmajerik
|
||||
LOGS: 'logs', // owner: @david @frank @olly @ross
|
||||
CSP_REPORTING: 'mexicspo', // owner @pauldambra @lricoy @robbiec
|
||||
} as const
|
||||
|
||||
@@ -34,9 +34,39 @@
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0.25em;
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
margin-bottom: 0.375em;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
padding-bottom: 0.375em;
|
||||
border-bottom-width: 1px;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
img {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import './LemonSkeleton.scss'
|
||||
|
||||
import clsx from 'clsx'
|
||||
import { LemonButtonProps } from 'lib/lemon-ui/LemonButton'
|
||||
import { range } from 'lib/utils'
|
||||
import { cn } from 'lib/utils/css-classes'
|
||||
|
||||
export interface LemonSkeletonProps {
|
||||
className?: string
|
||||
@@ -14,7 +14,7 @@ export interface LemonSkeletonProps {
|
||||
}
|
||||
export function LemonSkeleton({ className, repeat, active = true, fade = false }: LemonSkeletonProps): JSX.Element {
|
||||
const content = (
|
||||
<div className={clsx('LemonSkeleton rounded', !active && 'LemonSkeleton--static', className || 'h-4 w-full')}>
|
||||
<div className={cn('LemonSkeleton rounded', !active && 'LemonSkeleton--static', className || 'h-4 w-full')}>
|
||||
{/* The span is for accessibility, but also because @storybook/test-runner smoke tests require content */}
|
||||
<span>Loading…</span>
|
||||
</div>
|
||||
@@ -36,15 +36,15 @@ export function LemonSkeleton({ className, repeat, active = true, fade = false }
|
||||
}
|
||||
|
||||
LemonSkeleton.Text = function LemonSkeletonText({ className, ...props }: LemonSkeletonProps) {
|
||||
return <LemonSkeleton className={clsx('rounded h-6 w-full', className)} {...props} />
|
||||
return <LemonSkeleton className={cn('rounded h-6 w-full', className)} {...props} />
|
||||
}
|
||||
|
||||
LemonSkeleton.Row = function LemonSkeletonRow({ className, ...props }: LemonSkeletonProps) {
|
||||
return <LemonSkeleton className={clsx('rounded h-10 w-full', className)} {...props} />
|
||||
return <LemonSkeleton className={cn('rounded h-10 w-full', className)} {...props} />
|
||||
}
|
||||
|
||||
LemonSkeleton.Circle = function LemonSkeletonCircle({ className, ...props }: LemonSkeletonProps) {
|
||||
return <LemonSkeleton className={clsx('rounded-full shrink-0', className || 'h-10 w-10')} {...props} />
|
||||
return <LemonSkeleton className={cn('rounded-full shrink-0', className || 'h-10 w-10')} {...props} />
|
||||
}
|
||||
|
||||
LemonSkeleton.Button = function LemonSkeletonButton({
|
||||
@@ -54,7 +54,7 @@ LemonSkeleton.Button = function LemonSkeletonButton({
|
||||
}: LemonSkeletonProps & { size?: LemonButtonProps['size'] }) {
|
||||
return (
|
||||
<LemonSkeleton
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'rounded px-3',
|
||||
size === 'small' && 'h-10',
|
||||
(!size || size === 'medium') && 'h-10',
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface LemonTabsProps<T extends string | number> {
|
||||
size?: 'small' | 'medium'
|
||||
'data-attr'?: string
|
||||
barClassName?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface LemonTabsCSSProperties extends React.CSSProperties {
|
||||
@@ -45,6 +46,7 @@ export function LemonTabs<T extends string | number>({
|
||||
tabs,
|
||||
barClassName,
|
||||
size = 'medium',
|
||||
className,
|
||||
'data-attr': dataAttr,
|
||||
}: LemonTabsProps<T>): JSX.Element {
|
||||
const { containerRef, selectionRef, sliderWidth, sliderOffset, transitioning } = useSliderPositioning<
|
||||
@@ -58,7 +60,7 @@ export function LemonTabs<T extends string | number>({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('LemonTabs', transitioning && 'LemonTabs--transitioning', `LemonTabs--${size}`)}
|
||||
className={clsx('LemonTabs', transitioning && 'LemonTabs--transitioning', `LemonTabs--${size}`, className)}
|
||||
// eslint-disable-next-line react/forbid-dom-props
|
||||
style={
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ import React, { useRef, useState } from 'react'
|
||||
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
|
||||
|
||||
export const LemonTextAreaMarkdown = React.forwardRef<HTMLTextAreaElement, LemonTextAreaProps>(
|
||||
function _LemonTextAreaMarkdown({ value, onChange, ...editAreaProps }, ref): JSX.Element {
|
||||
function _LemonTextAreaMarkdown({ value, onChange, className, ...editAreaProps }, ref): JSX.Element {
|
||||
const { objectStorageAvailable } = useValues(preflightLogic)
|
||||
|
||||
const [isPreviewShown, setIsPreviewShown] = useState(false)
|
||||
@@ -34,6 +34,7 @@ export const LemonTextAreaMarkdown = React.forwardRef<HTMLTextAreaElement, Lemon
|
||||
<LemonTabs
|
||||
activeKey={isPreviewShown ? 'preview' : 'write'}
|
||||
onChange={(key) => setIsPreviewShown(key === 'preview')}
|
||||
className={className}
|
||||
tabs={[
|
||||
{
|
||||
key: 'write',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// The imports are preserved between builds, so please update if any are missing or extra.
|
||||
|
||||
import {
|
||||
IconChat,
|
||||
IconDashboard,
|
||||
IconGraph,
|
||||
IconMegaphone,
|
||||
@@ -57,6 +58,8 @@ export const productScenes: Record<string, () => Promise<any>> = {
|
||||
MessagingLibrary: () => import('../../products/messaging/frontend/library/MessageLibrary'),
|
||||
MessagingLibraryTemplate: () => import('../../products/messaging/frontend/library/MessageTemplate'),
|
||||
RevenueAnalytics: () => import('../../products/revenue_analytics/frontend/RevenueAnalyticsScene'),
|
||||
UserInterviews: () => import('../../products/user_interviews/frontend/UserInterviews'),
|
||||
UserInterview: () => import('../../products/user_interviews/frontend/UserInterview'),
|
||||
}
|
||||
|
||||
/** This const is auto-generated, as is the whole file */
|
||||
@@ -85,6 +88,8 @@ export const productRoutes: Record<string, [string, string]> = {
|
||||
'messagingLibraryTemplateFromMessage',
|
||||
],
|
||||
'/revenue_analytics': ['RevenueAnalytics', 'revenueAnalytics'],
|
||||
'/user_interviews': ['UserInterviews', 'userInterviews'],
|
||||
'/user_interviews/:id': ['UserInterview', 'userInterview'],
|
||||
}
|
||||
|
||||
/** This const is auto-generated, as is the whole file */
|
||||
@@ -140,6 +145,8 @@ export const productConfiguration: Record<string, any> = {
|
||||
defaultDocsPath: '/docs/web-analytics/revenue-analytics',
|
||||
activityScope: 'RevenueAnalytics',
|
||||
},
|
||||
UserInterviews: { name: 'User interviews', projectBased: true, activityScope: 'UserInterview' },
|
||||
UserInterview: { name: 'User interview', projectBased: true, activityScope: 'UserInterview' },
|
||||
}
|
||||
|
||||
/** This const is auto-generated, as is the whole file */
|
||||
@@ -286,6 +293,8 @@ export const productUrls = {
|
||||
surveys: (tab?: SurveysTabs): string => `/surveys${tab ? `?tab=${tab}` : ''}`,
|
||||
survey: (id: string): string => `/surveys/${id}`,
|
||||
surveyTemplates: (): string => '/survey_templates',
|
||||
userInterviews: (): string => '/user_interviews',
|
||||
userInterview: (id: string): string => `/user_interviews/${id}`,
|
||||
webAnalytics: (): string => `/web`,
|
||||
webAnalyticsWebVitals: (): string => `/web/web-vitals`,
|
||||
webAnalyticsPageReports: (): string => `/web/page-reports`,
|
||||
@@ -305,6 +314,7 @@ export const fileSystemTypes = {
|
||||
notebook: { icon: <IconNotebook />, href: (ref: string) => urls.notebook(ref) },
|
||||
session_recording_playlist: { icon: <IconRewindPlay />, href: (ref: string) => urls.replayPlaylist(ref) },
|
||||
survey: { icon: <IconMessage />, href: (ref: string) => urls.survey(ref) },
|
||||
user_interview: { icon: <IconChat />, href: (ref: string) => urls.userInterview(ref) },
|
||||
}
|
||||
|
||||
/** This const is auto-generated, as is the whole file */
|
||||
@@ -360,6 +370,7 @@ export const getTreeItemsProducts = (): FileSystemImport[] => [
|
||||
{ path: 'Revenue settings', iconType: 'handMoney', href: urls.revenueSettings() },
|
||||
{ path: 'Session replay', href: urls.replay(ReplayTabs.Home), type: 'session_recording_playlist' },
|
||||
{ path: 'Surveys', type: 'survey', href: urls.surveys() },
|
||||
{ path: 'User interviews', href: urls.userInterviews(), type: 'user_interview' },
|
||||
{ path: 'Web analytics', iconType: 'pieChart', href: urls.webAnalytics() },
|
||||
]
|
||||
|
||||
@@ -378,4 +389,5 @@ export const getTreeFilterTypes = (): Record<string, FileSystemFilterType> => ({
|
||||
notebook: { name: 'Notebooks' },
|
||||
insight: { name: 'Insights' },
|
||||
session_recording_playlist: { name: 'Replay playlists' },
|
||||
user_interview: { name: 'User interviews' },
|
||||
})
|
||||
|
||||
@@ -621,7 +621,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"AssistantContextualTool": {
|
||||
"enum": ["search_session_recordings", "generate_hogql_query", "fix_hogql_query"],
|
||||
"enum": ["search_session_recordings", "generate_hogql_query", "fix_hogql_query", "analyze_user_interviews"],
|
||||
"type": "string"
|
||||
},
|
||||
"AssistantDateRange": {
|
||||
|
||||
@@ -108,4 +108,8 @@ export interface AssistantToolCallMessage extends BaseAssistantMessage {
|
||||
tool_call_id: string
|
||||
}
|
||||
|
||||
export type AssistantContextualTool = 'search_session_recordings' | 'generate_hogql_query' | 'fix_hogql_query'
|
||||
export type AssistantContextualTool =
|
||||
| 'search_session_recordings'
|
||||
| 'generate_hogql_query'
|
||||
| 'fix_hogql_query'
|
||||
| 'analyze_user_interviews'
|
||||
|
||||
@@ -98,6 +98,8 @@ export enum Scene {
|
||||
Wizard = 'Wizard',
|
||||
StartupProgram = 'StartupProgram',
|
||||
HogFunction = 'HogFunction',
|
||||
UserInterviews = 'UserInterviews',
|
||||
UserInterview = 'UserInterview',
|
||||
Game368 = 'Game368',
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,7 @@ export enum ProductKey {
|
||||
DATA_WAREHOUSE = 'data_warehouse',
|
||||
DATA_WAREHOUSE_SAVED_QUERY = 'data_warehouse_saved_queries',
|
||||
EARLY_ACCESS_FEATURES = 'early_access_features',
|
||||
USER_INTERVIEWS = 'user_interviews',
|
||||
PRODUCT_ANALYTICS = 'product_analytics',
|
||||
PIPELINE_TRANSFORMATIONS = 'pipeline_transformations',
|
||||
PIPELINE_DESTINATIONS = 'pipeline_destinations',
|
||||
@@ -3560,6 +3561,15 @@ export interface Group {
|
||||
group_properties: Record<string, any>
|
||||
}
|
||||
|
||||
export interface UserInterviewType {
|
||||
id: string
|
||||
created_by: UserBasicType
|
||||
created_at: string
|
||||
transcript: string
|
||||
summary: string
|
||||
interviewee_emails: string[]
|
||||
}
|
||||
|
||||
export enum ExperimentConclusion {
|
||||
Won = 'won',
|
||||
Lost = 'lost',
|
||||
@@ -4303,6 +4313,7 @@ export enum ActivityScope {
|
||||
TEAM = 'Team',
|
||||
ERROR_TRACKING_ISSUE = 'ErrorTrackingIssue',
|
||||
DATA_WAREHOUSE_SAVED_QUERY = 'DataWarehouseSavedQuery',
|
||||
USER_INTERVIEW = 'UserInterview',
|
||||
}
|
||||
|
||||
export type CommentType = {
|
||||
|
||||
32
pnpm-lock.yaml
generated
@@ -620,6 +620,9 @@ importers:
|
||||
'@posthog/products-surveys':
|
||||
specifier: workspace:*
|
||||
version: link:../products/surveys
|
||||
'@posthog/products-user-interviews':
|
||||
specifier: workspace:*
|
||||
version: link:../products/user_interviews
|
||||
'@posthog/products-web-analytics':
|
||||
specifier: workspace:*
|
||||
version: link:../products/web_analytics
|
||||
@@ -1842,7 +1845,7 @@ importers:
|
||||
version: link:../../common/eslint_rules
|
||||
jest:
|
||||
specifier: '*'
|
||||
version: 29.7.0(@types/node@18.18.4)(ts-node@10.9.1(@swc/core@1.10.14(@swc/helpers@0.5.15))(@types/node@18.18.4)(typescript@4.9.5))
|
||||
version: 29.7.0(@types/node@18.18.4)(ts-node@10.9.1(@swc/core@1.11.4(@swc/helpers@0.5.15))(@types/node@18.18.4)(typescript@4.9.5))
|
||||
kea:
|
||||
specifier: '*'
|
||||
version: 3.1.5(react@18.2.0)
|
||||
@@ -2060,6 +2063,33 @@ importers:
|
||||
specifier: '*'
|
||||
version: 18.2.0
|
||||
|
||||
products/user_interviews:
|
||||
dependencies:
|
||||
'@posthog/icons':
|
||||
specifier: 0.13.1
|
||||
version: 0.13.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@types/react':
|
||||
specifier: '*'
|
||||
version: 17.0.52
|
||||
clsx:
|
||||
specifier: '*'
|
||||
version: 1.2.1
|
||||
kea:
|
||||
specifier: '*'
|
||||
version: 3.1.5(react@18.2.0)
|
||||
kea-loaders:
|
||||
specifier: '*'
|
||||
version: 3.0.0(kea@3.1.5(react@18.2.0))
|
||||
kea-router:
|
||||
specifier: '*'
|
||||
version: 3.2.1(kea@3.1.5(react@18.2.0))
|
||||
posthog-js:
|
||||
specifier: '*'
|
||||
version: 1.242.2(@rrweb/types@2.0.0-alpha.17)
|
||||
react:
|
||||
specifier: '*'
|
||||
version: 18.2.0
|
||||
|
||||
products/web_analytics:
|
||||
dependencies:
|
||||
'@posthog/icons':
|
||||
|
||||
@@ -3,6 +3,9 @@ from rest_framework_extensions.routers import NestedRegistryItem
|
||||
|
||||
import products.data_warehouse.backend.api.fix_hogql as fix_hogql
|
||||
import products.early_access_features.backend.api as early_access_feature
|
||||
from products.user_interviews.backend.api import UserInterviewViewSet
|
||||
from products.editor.backend.api import LLMProxyViewSet, MaxToolsViewSet
|
||||
from products.messaging.backend.api import MessageTemplatesViewSet
|
||||
import products.logs.backend.api as logs
|
||||
from posthog.api import data_color_theme, metalytics, project, wizard
|
||||
from posthog.api.csp_reporting import CSPReportingViewSet
|
||||
@@ -19,8 +22,6 @@ from posthog.warehouse.api import (
|
||||
table,
|
||||
view_link,
|
||||
)
|
||||
from products.editor.backend.api import LLMProxyViewSet, MaxToolsViewSet
|
||||
from products.messaging.backend.api import MessageTemplatesViewSet
|
||||
|
||||
from ..heatmaps.heatmaps_api import HeatmapViewSet, LegacyHeatmapViewSet
|
||||
from ..session_recordings.session_recording_api import SessionRecordingViewSet
|
||||
@@ -686,6 +687,13 @@ environments_router.register(
|
||||
# Logs endpoints
|
||||
register_grandfathered_environment_nested_viewset(r"logs", logs.LogsViewSet, "environment_logs", ["team_id"])
|
||||
|
||||
environments_router.register(
|
||||
r"user_interviews",
|
||||
UserInterviewViewSet,
|
||||
"environment_user_interviews",
|
||||
["team_id"],
|
||||
)
|
||||
|
||||
environments_router.register(
|
||||
r"csp-reporting",
|
||||
CSPReportingViewSet,
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
'/home/runner/work/posthog/posthog/products/editor/backend/api/max_tools.py: Warning [MaxToolsViewSet]: could not derive type of path parameter "project_id" because model "ee.models.assistant.Conversation" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
|
||||
'/home/runner/work/posthog/posthog/products/logs/backend/api.py: Error [LogsViewSet]: unable to guess serializer. This is graceful fallback handling for APIViews. Consider using GenericAPIView as view base class, if view is under your control. Either way you may want to add a serializer_class (or method). Ignoring view for now.',
|
||||
'/home/runner/work/posthog/posthog/products/logs/backend/api.py: Warning [LogsViewSet]: could not derive type of path parameter "project_id" because it is untyped and obtaining queryset from the viewset failed. Consider adding a type to the path (e.g. <int:project_id>) or annotating the parameter type with @extend_schema. Defaulting to "string".',
|
||||
'/home/runner/work/posthog/posthog/products/user_interviews/backend/api.py: Warning [UserInterviewViewSet]: could not derive type of path parameter "project_id" because model "products.user_interviews.backend.models.UserInterview" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
|
||||
'/opt/hostedtoolcache/Python/3.11.9/x64/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py: Warning [QueryViewSet > ModelMetaclass]: Encountered 2 components with identical names "Person" and different classes <class \'str\'> and <class \'posthog.api.person.PersonSerializer\'>. This will very likely result in an incorrect schema. Try renaming one.',
|
||||
'Warning: encountered multiple names for the same choice set (EffectivePrivilegeLevelEnum). This may be unwanted even though the generated schema is technically correct. Add an entry to ENUM_NAME_OVERRIDES to fix the naming.',
|
||||
'Warning: encountered multiple names for the same choice set (HrefMatchingEnum). This may be unwanted even though the generated schema is technically correct. Add an entry to ENUM_NAME_OVERRIDES to fix the naming.',
|
||||
|
||||
@@ -40,6 +40,7 @@ APIScopeObject = Literal[
|
||||
"subscription",
|
||||
"survey",
|
||||
"user",
|
||||
"user_interview_DO_NOT_USE", # This is a super alpha product, so only exposing here for internal personal API key access
|
||||
"webhook",
|
||||
"logs", # logs product
|
||||
]
|
||||
|
||||
@@ -65,6 +65,7 @@ class AssistantContextualTool(StrEnum):
|
||||
SEARCH_SESSION_RECORDINGS = "search_session_recordings"
|
||||
GENERATE_HOGQL_QUERY = "generate_hogql_query"
|
||||
FIX_HOGQL_QUERY = "fix_hogql_query"
|
||||
ANALYZE_USER_INTERVIEWS = "analyze_user_interviews"
|
||||
|
||||
|
||||
class AssistantDateRange(BaseModel):
|
||||
|
||||
@@ -31,6 +31,7 @@ PRODUCTS_APPS = [
|
||||
"products.early_access_features",
|
||||
"products.editor",
|
||||
"products.revenue_analytics",
|
||||
"products.user_interviews",
|
||||
]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
|
||||
0
products/user_interviews/__init__.py
Normal file
219
products/user_interviews/backend/api.py
Normal file
@@ -0,0 +1,219 @@
|
||||
from functools import cached_property
|
||||
import json
|
||||
import re
|
||||
from uuid import uuid4
|
||||
import posthoganalytics.ai.openai
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.parsers import MultiPartParser, JSONParser
|
||||
|
||||
from posthog.api.routing import TeamAndOrgViewSetMixin
|
||||
from .models import UserInterview
|
||||
from elevenlabs import ElevenLabs
|
||||
import posthoganalytics
|
||||
from rest_framework import serializers
|
||||
from django.core.files import File
|
||||
from posthog.api.shared import UserBasicSerializer
|
||||
from posthoganalytics.ai.openai import OpenAI
|
||||
|
||||
|
||||
elevenlabs_client = ElevenLabs()
|
||||
|
||||
|
||||
class UserInterviewSerializer(serializers.ModelSerializer):
|
||||
created_by = UserBasicSerializer(read_only=True)
|
||||
audio = serializers.FileField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = UserInterview
|
||||
fields = (
|
||||
"id",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"interviewee_emails",
|
||||
"transcript",
|
||||
"summary",
|
||||
"audio",
|
||||
)
|
||||
read_only_fields = ("id", "created_by", "created_at", "transcript")
|
||||
|
||||
def create(self, validated_data):
|
||||
request = self.context["request"]
|
||||
validated_data["created_by"] = request.user
|
||||
validated_data["team_id"] = self.context["team_id"]
|
||||
audio = validated_data.pop("audio")
|
||||
validated_data["transcript"] = self._transcribe_audio(audio, validated_data["interviewee_emails"])
|
||||
validated_data["summary"] = self._summarize_transcript(validated_data["transcript"])
|
||||
return super().create(validated_data)
|
||||
|
||||
def _transcribe_audio(self, audio: File, interviewee_emails: list[str]):
|
||||
transcript = elevenlabs_client.speech_to_text.convert(
|
||||
model_id="scribe_v1",
|
||||
file=audio,
|
||||
num_speakers=10, # Maximum number of speakers, not expected one
|
||||
diarize=True,
|
||||
tag_audio_events=False,
|
||||
additional_formats=json.dumps( # type: ignore
|
||||
[
|
||||
{
|
||||
"format": "txt",
|
||||
"include_timestamps": False,
|
||||
"segment_on_silence_longer_than_s": 10,
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
transcript_text = transcript.additional_formats[0].content.strip() # type: ignore
|
||||
|
||||
speaker_mapping = self._attempt_to_map_speaker_names(transcript_text, interviewee_emails)
|
||||
if speaker_mapping:
|
||||
for speaker_marker, speaker_name in speaker_mapping.items():
|
||||
transcript_text = transcript_text.replace(speaker_marker, speaker_name)
|
||||
formatted_transcript_text = re.sub(r"\[(.+)\]", "#### \\1", transcript_text)
|
||||
else:
|
||||
# Always fall back to formatting speaker numbers if we can't map names
|
||||
formatted_transcript_text = re.sub(r"\[speaker_(\d+)\]", "#### Speaker \\1", transcript_text)
|
||||
|
||||
return formatted_transcript_text
|
||||
|
||||
def _attempt_to_map_speaker_names(self, transcript: str, interviewee_emails: list[str]) -> dict[str, str] | None:
|
||||
participant_emails_joined = "\n".join(f"- {email}" for email in interviewee_emails)
|
||||
assignment_response = OpenAI(posthog_client=posthoganalytics.default_client).responses.create( # type: ignore
|
||||
model="gpt-4.1-mini",
|
||||
posthog_trace_id=self._ai_trace_id,
|
||||
posthog_distinct_id=self.context["request"].user.distinct_id,
|
||||
input=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": """
|
||||
Your task is to map speakers in a transcript to the actual names of the people who spoke. Each speaker is identified by a number.
|
||||
Use clues such as who the speaker is calling out (e.g. they wouldn't greet themselves) or what they're talking about (e.g. how they use company names).
|
||||
|
||||
Your output should be a JSON mapping between "speaker_<n>" and "<speaker name>".
|
||||
|
||||
<handling_ambiguity>
|
||||
- Use just the person's display name if available, otherwise use their email. If two people with the same full name are present, include their email to disambiguate.
|
||||
- Likely many of the participants have spoken, but not necessarily all of them.
|
||||
- Keep in mind it's possible there were additional unexpected participants (though not that likely).
|
||||
- The transcript is not going to be perfect, so some names in the transcript may be slightly mangled compared to display names in participant emails.
|
||||
E.g. the transcript may contain that an interviewer greeted "Jon", but if the participant emails only have a "John", it's safe to assume that the interviewer was talking to John.
|
||||
- If most of the speakers are entirely obvious, but only a small subset isn't, mark the unidentified speakers' names as "Unknown #1 (<candidate_1> or <candidate_2>)" etc. Don't leave any speaker unmarked.
|
||||
If however you cannot infer a reliable mapping for most speakers (the transcript has no useful information or is too chaotic), return simply: null.
|
||||
</handling_ambiguity>
|
||||
|
||||
<example>
|
||||
As an example, for transcript:
|
||||
|
||||
<participant_emails>
|
||||
- Michael F. Doe <michael@x.com>
|
||||
- Steve Jobs <steve@apple.com>
|
||||
</participant_emails>
|
||||
<transcript>
|
||||
[speaker_0]
|
||||
Hi Michael! How big is your company?
|
||||
|
||||
[speaker_1]
|
||||
Hey! We're about 200 people.
|
||||
|
||||
[speaker_0]
|
||||
That's great!
|
||||
</transcript>
|
||||
|
||||
Your output should be:
|
||||
|
||||
{
|
||||
"speaker_0": "Steve Jobs",
|
||||
"speaker_1": "Michael F. Doe"
|
||||
}
|
||||
</example>
|
||||
|
||||
<output_format>
|
||||
Output must always be valid JSON - either an object or null.
|
||||
</output_format>
|
||||
""".strip(),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"""
|
||||
Map the speakers in the following transcript:
|
||||
|
||||
<participant_emails>
|
||||
{participant_emails_joined}
|
||||
</participant_emails>
|
||||
|
||||
<transcript>
|
||||
{transcript}
|
||||
</transcript>
|
||||
""".strip(),
|
||||
},
|
||||
],
|
||||
)
|
||||
try:
|
||||
return json.loads(assignment_response.output_text)
|
||||
except json.JSONDecodeError:
|
||||
posthoganalytics.capture_exception()
|
||||
return None
|
||||
|
||||
def _summarize_transcript(self, transcript: str):
|
||||
summary_response = OpenAI(posthog_client=posthoganalytics.default_client).responses.create( # type: ignore
|
||||
model="gpt-4.1-mini",
|
||||
posthog_trace_id=self._ai_trace_id,
|
||||
posthog_distinct_id=self.context["request"].user.distinct_id,
|
||||
input=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": """
|
||||
You are an expert product manager, and your sole task is to summarizes user interviews ran by our team.
|
||||
""".strip(),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"""
|
||||
I interviewed a user to gather insights about our product. The goal is to capture the customer's feedback, experiences, and suggestions in a detailed and organized manner.
|
||||
The notes should be comprehensive but focused, allowing for the detailed documentation of both qualitative insights and actionable items.
|
||||
Pull out direct quotes and figures whenever relevant.
|
||||
|
||||
Because no better transcript is available, you should still do your best to summarize the interview.
|
||||
|
||||
<summary_format>
|
||||
## User background
|
||||
|
||||
Capture relevant details about the user, including their role, experience, and how they interact with your product or service.
|
||||
Note down here any existing solutions or workarounds they use.
|
||||
|
||||
## Current product usage
|
||||
|
||||
Document how the user is currently using the product, including frequency of use, key features used, and any specific use cases.
|
||||
|
||||
## Positive feedback and pain points
|
||||
|
||||
Summarize the positive feedback the user provided, as well as any pain points or challenges they are experiencing with the product.
|
||||
|
||||
## Impact of the product
|
||||
|
||||
Record the impact the product has had on the user's work or life, including any improvements or changes it has enabled.
|
||||
|
||||
## Next steps and follow-up
|
||||
|
||||
Record the agreed-upon next steps, including any additional actions that need to be taken, follow-up tasks, and who is responsible for them.
|
||||
</summary_format>
|
||||
|
||||
<transcript>
|
||||
{transcript}
|
||||
</transcript>
|
||||
""".strip(),
|
||||
},
|
||||
],
|
||||
)
|
||||
return summary_response.output_text
|
||||
|
||||
@cached_property
|
||||
def _ai_trace_id(self):
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
class UserInterviewViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet):
|
||||
scope_object = "user_interview_DO_NOT_USE"
|
||||
queryset = UserInterview.objects.order_by("-created_at").select_related("created_by").all()
|
||||
serializer_class = UserInterviewSerializer
|
||||
parser_classes = [MultiPartParser, JSONParser]
|
||||
66
products/user_interviews/backend/max_tools.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from ee.hogai.tool import MaxTool
|
||||
from pydantic import BaseModel, Field
|
||||
from openai import OpenAI
|
||||
from typing import Any
|
||||
from .models import UserInterview
|
||||
|
||||
|
||||
class AnalyzeUserInterviewsArgs(BaseModel):
|
||||
analysis_angle: str = Field(
|
||||
description="How to analyze the interviews based on user's question (e.g. 'Find common pain points', 'Identify feature requests', etc.)"
|
||||
)
|
||||
|
||||
|
||||
class AnalyzeUserInterviewsTool(MaxTool):
|
||||
name: str = "analyze_user_interviews"
|
||||
description: str = "Analyze all user interviews from a specific angle to find patterns and insights"
|
||||
thinking_message: str = "Analyzing user interviews"
|
||||
root_system_prompt_template: str = "Since the user is currently on the user interviews page, you should lean towards the `analyze_user_interviews` when it comes to any questions about users or customers."
|
||||
args_schema: type[BaseModel] = AnalyzeUserInterviewsArgs
|
||||
|
||||
def _run_impl(self, analysis_angle: str) -> tuple[str, Any]:
|
||||
# Get all interviews for the current team
|
||||
interviews = UserInterview.objects.filter(team_id=self._team_id).order_by("-created_at")
|
||||
|
||||
if not interviews:
|
||||
return "No user interviews found to analyze.", None
|
||||
|
||||
# Prepare interview summaries for analysis
|
||||
interview_summaries = []
|
||||
for interview in interviews:
|
||||
if interview.summary:
|
||||
interview_summaries.append(f"Interview from {interview.created_at}:\n{interview.summary}\n")
|
||||
|
||||
if not interview_summaries:
|
||||
return "No interview summaries found to analyze.", None
|
||||
|
||||
interview_summaries = "\n\n".join(interview_summaries)
|
||||
|
||||
# Use GPT to analyze the summaries
|
||||
analysis_response = OpenAI().responses.create(
|
||||
model="gpt-4.1-mini",
|
||||
input=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": """
|
||||
You are an expert product manager analyzing user interviews. Your task is to analyze multiple interview summaries and provide insights based on the requested analysis angle.
|
||||
Focus on finding patterns, common themes, and actionable insights.
|
||||
""".strip(),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"""
|
||||
Please analyze these user interview summaries from the following angle:
|
||||
{analysis_angle}
|
||||
|
||||
<interview_summaries>
|
||||
{interview_summaries}
|
||||
</interview_summaries>
|
||||
|
||||
Provide a structured analysis with clear sections and bullet points where appropriate. Keep it very concise though. Avoid fluff, just give the facts to answer the question.
|
||||
""".strip(),
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
return analysis_response.output_text, None
|
||||
29
products/user_interviews/backend/models.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import re
|
||||
from django.db import models
|
||||
from posthog.models.team import Team
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from posthog.models.utils import UUIDModel, CreatedMetaFields
|
||||
from django.core import validators
|
||||
from django.utils.deconstruct import deconstructible
|
||||
|
||||
|
||||
@deconstructible
|
||||
class EmailWithDisplayNameValidator:
|
||||
# In "Michael (some guy) <michael@x.com>" display_name_regex's group 1 matches "Michael"
|
||||
# (round brackets are comments according to RFC #822, content in there is ignored), and group 2 matches "michael@x.com"
|
||||
display_name_regex = r"([^(]+) <(.+)>$"
|
||||
|
||||
def __call__(self, value):
|
||||
display_name_match = re.match(self.display_name_regex, value)
|
||||
if display_name_match:
|
||||
value = display_name_match.group(2).strip()
|
||||
return validators.validate_email(value)
|
||||
|
||||
|
||||
class UserInterview(UUIDModel, CreatedMetaFields):
|
||||
team = models.ForeignKey(Team, on_delete=models.CASCADE)
|
||||
interviewee_emails = ArrayField(
|
||||
models.CharField(max_length=254, validators=[EmailWithDisplayNameValidator()]), default=list
|
||||
)
|
||||
transcript = models.TextField(blank=True)
|
||||
summary = models.TextField(blank=True)
|
||||
157
products/user_interviews/frontend/UserInterview.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { IconCheck, IconPencil, IconX } from '@posthog/icons'
|
||||
import { LemonButton, LemonSkeleton, LemonTag, LemonTextAreaMarkdown } from '@posthog/lemon-ui'
|
||||
import { useAsyncActions, useValues } from 'kea'
|
||||
import { NotFound } from 'lib/components/NotFound'
|
||||
import { PageHeader } from 'lib/components/PageHeader'
|
||||
import { dayjs } from 'lib/dayjs'
|
||||
import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown'
|
||||
import { LemonWidget } from 'lib/lemon-ui/LemonWidget/LemonWidget'
|
||||
import posthog from 'posthog-js'
|
||||
import { useState } from 'react'
|
||||
import { PersonDisplay } from 'scenes/persons/PersonDisplay'
|
||||
import { SceneExport } from 'scenes/sceneTypes'
|
||||
|
||||
import { UserInterviewType } from '~/types'
|
||||
|
||||
import { userInterviewLogic } from './userInterviewLogic'
|
||||
|
||||
export const scene: SceneExport = {
|
||||
component: UserInterview,
|
||||
logic: userInterviewLogic,
|
||||
paramsToProps: ({ params: { id } }): (typeof userInterviewLogic)['props'] => ({ id }),
|
||||
}
|
||||
|
||||
export function UserInterview(): JSX.Element {
|
||||
const { userInterview, userInterviewLoading } = useValues(userInterviewLogic)
|
||||
const { updateUserInterview } = useAsyncActions(userInterviewLogic)
|
||||
|
||||
const [summaryInEditing, setSummaryInEditing] = useState<string | null>(null)
|
||||
|
||||
if (userInterviewLoading && !userInterview) {
|
||||
return (
|
||||
<div className="@container">
|
||||
<PageHeader caption={<LemonSkeleton.Text className="w-48 h-4" />} />
|
||||
<div className="grid grid-cols-1 items-start gap-4 @4xl:grid-cols-3">
|
||||
<LemonWidget title="Summary" className="col-span-2">
|
||||
<div className="space-y-1.5 p-3">
|
||||
<LemonSkeleton.Text className="h-6 w-[20%]" />
|
||||
<LemonSkeleton.Text className="h-3 w-[60%]" />
|
||||
<LemonSkeleton.Text className="h-3 w-[70%]" />
|
||||
<LemonSkeleton.Text className="h-3 w-[80%]" />
|
||||
<LemonSkeleton.Text className="h-3 w-[40%]" />
|
||||
<LemonSkeleton.Text className="h-3 w-[55%]" />
|
||||
<LemonSkeleton.Text className="h-3 w-[65%]" />
|
||||
</div>
|
||||
</LemonWidget>
|
||||
<LemonWidget title="Transcript" className="col-span-1">
|
||||
<div className="space-y-1.5 p-3">
|
||||
<LemonSkeleton.Text className="h-3 w-[80%]" />
|
||||
<LemonSkeleton.Text className="h-3 w-[40%]" />
|
||||
<LemonSkeleton.Text className="h-3 w-[60%]" />
|
||||
<LemonSkeleton.Text className="h-3 w-[70%]" />
|
||||
<LemonSkeleton.Text className="h-3 w-[80%]" />
|
||||
<LemonSkeleton.Text className="h-3 w-[40%]" />
|
||||
<LemonSkeleton.Text className="h-3 w-[60%]" />
|
||||
<LemonSkeleton.Text className="h-3 w-[70%]" />
|
||||
</div>
|
||||
</LemonWidget>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!userInterview) {
|
||||
return <NotFound object="user interview" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="@container">
|
||||
<PageHeader caption={<InterviewMetadata interview={userInterview} />} />
|
||||
<div className="grid grid-cols-1 items-start gap-4 @4xl:grid-cols-3">
|
||||
<LemonWidget
|
||||
title="Summary"
|
||||
className="col-span-2"
|
||||
actions={
|
||||
summaryInEditing !== null ? (
|
||||
<>
|
||||
<LemonButton
|
||||
size="xsmall"
|
||||
icon={<IconX />}
|
||||
tooltip="Discard changes"
|
||||
onClick={() => setSummaryInEditing(null)}
|
||||
disabledReason={userInterviewLoading ? 'Saving…' : undefined}
|
||||
/>
|
||||
<LemonButton
|
||||
size="xsmall"
|
||||
icon={<IconCheck />}
|
||||
tooltip="Save"
|
||||
onClick={() => {
|
||||
updateUserInterview({ summary: summaryInEditing })
|
||||
.then(() => {
|
||||
setSummaryInEditing(null)
|
||||
})
|
||||
.catch((e) => posthog.captureException(e))
|
||||
}}
|
||||
loading={userInterviewLoading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<LemonButton
|
||||
size="xsmall"
|
||||
icon={<IconPencil />}
|
||||
onClick={() => setSummaryInEditing(userInterview.summary || '')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{summaryInEditing !== null ? (
|
||||
<LemonTextAreaMarkdown
|
||||
value={summaryInEditing}
|
||||
onChange={(newValue) => setSummaryInEditing(newValue)}
|
||||
className="pb-2 px-3"
|
||||
/>
|
||||
) : (
|
||||
<LemonMarkdown className="p-3">
|
||||
{userInterview.summary || '_No summary available._'}
|
||||
</LemonMarkdown>
|
||||
)}
|
||||
</LemonWidget>
|
||||
<div className="col-span-1 flex flex-col gap-y-4">
|
||||
<LemonWidget title="Participants">
|
||||
<div className="p-3 flex flex-col gap-y-2">
|
||||
{userInterview.interviewee_emails.map((interviewee_email) => (
|
||||
<PersonDisplay
|
||||
key={interviewee_email}
|
||||
person={{
|
||||
properties: {
|
||||
email: interviewee_email,
|
||||
},
|
||||
distinct_id: interviewee_email,
|
||||
}}
|
||||
withIcon
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</LemonWidget>
|
||||
<LemonWidget title="Transcript">
|
||||
<LemonMarkdown className="p-3">
|
||||
{userInterview.transcript || '_No transcript available._'}
|
||||
</LemonMarkdown>
|
||||
</LemonWidget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InterviewMetadata({ interview }: { interview: UserInterviewType }): JSX.Element {
|
||||
return (
|
||||
<header className="flex gap-x-2 gap-y-1 flex-wrap items-center">
|
||||
{interview.created_at && (
|
||||
<LemonTag className="bg-bg-light">
|
||||
Created: {dayjs(interview.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</LemonTag>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
77
products/user_interviews/frontend/UserInterviews.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { IconDownload } from '@posthog/icons'
|
||||
import { LemonButton, LemonTable, LemonTableColumn } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { PhonePairHogs } from 'lib/components/hedgehogs'
|
||||
import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction'
|
||||
import { createdAtColumn, createdByColumn } from 'lib/lemon-ui/LemonTable/columnUtils'
|
||||
import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink'
|
||||
import { MaxTool } from 'scenes/max/MaxTool'
|
||||
import { SceneExport } from 'scenes/sceneTypes'
|
||||
import { urls } from 'scenes/urls'
|
||||
import { userLogic } from 'scenes/userLogic'
|
||||
|
||||
import { ProductKey, UserInterviewType } from '~/types'
|
||||
|
||||
import { userInterviewsLogic } from './userInterviewsLogic'
|
||||
|
||||
export const scene: SceneExport = {
|
||||
component: UserInterviews,
|
||||
logic: userInterviewsLogic,
|
||||
}
|
||||
|
||||
export function UserInterviews(): JSX.Element {
|
||||
const { userInterviews, userInterviewsLoading } = useValues(userInterviewsLogic)
|
||||
|
||||
const { updateHasSeenProductIntroFor } = useActions(userLogic)
|
||||
return (
|
||||
<>
|
||||
<ProductIntroduction
|
||||
productName="User interviews"
|
||||
productKey={ProductKey.USER_INTERVIEWS}
|
||||
thingName="user interview"
|
||||
description="Make full use of user interviews by recording them with PostHog."
|
||||
customHog={PhonePairHogs}
|
||||
isEmpty={!userInterviewsLoading && userInterviews.length === 0}
|
||||
actionElementOverride={
|
||||
<LemonButton
|
||||
type="primary"
|
||||
icon={<IconDownload />}
|
||||
onClick={() => updateHasSeenProductIntroFor(ProductKey.USER_INTERVIEWS, true)}
|
||||
to="https://posthog.com/recorder"
|
||||
data-attr="install-recorder"
|
||||
>
|
||||
Install PostHog Recorder
|
||||
</LemonButton>
|
||||
}
|
||||
/>
|
||||
<MaxTool
|
||||
name="analyze_user_interviews"
|
||||
displayName="Analyze user interviews"
|
||||
context={{}}
|
||||
callback={() => {
|
||||
// No need to handle structured output for this tool
|
||||
}}
|
||||
>
|
||||
<LemonTable
|
||||
loading={userInterviewsLoading}
|
||||
columns={[
|
||||
{
|
||||
title: 'Interviewees',
|
||||
key: 'interviewees',
|
||||
render: (_, row) => (
|
||||
<LemonTableLink
|
||||
title={row.interviewee_emails.join(', ')}
|
||||
to={urls.userInterview(row.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
createdAtColumn() as LemonTableColumn<UserInterviewType, keyof UserInterviewType | undefined>,
|
||||
createdByColumn() as LemonTableColumn<UserInterviewType, keyof UserInterviewType | undefined>,
|
||||
]}
|
||||
dataSource={userInterviews}
|
||||
loadingSkeletonRows={5}
|
||||
/>
|
||||
</MaxTool>
|
||||
</>
|
||||
)
|
||||
}
|
||||
59
products/user_interviews/frontend/userInterviewLogic.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { afterMount, kea, key, path, props, selectors } from 'kea'
|
||||
import { loaders } from 'kea-loaders'
|
||||
import api from 'lib/api'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { Breadcrumb, UserInterviewType } from '~/types'
|
||||
|
||||
import type { userInterviewLogicType } from './userInterviewLogicType'
|
||||
import { userInterviewsLogic } from './userInterviewsLogic'
|
||||
|
||||
export interface UserInterviewLogicProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const userInterviewLogic = kea<userInterviewLogicType>([
|
||||
path(['products', 'user_interviews', 'frontend', 'userInterviewLogic']),
|
||||
props({} as UserInterviewLogicProps),
|
||||
key((props) => props.id),
|
||||
loaders(({ props }) => ({
|
||||
userInterview: [
|
||||
userInterviewsLogic.findMounted()?.values.userInterviews.find((interview) => interview.id === props.id) ||
|
||||
null,
|
||||
{
|
||||
loadUserInterview: async (id: string): Promise<UserInterviewType | null> => {
|
||||
try {
|
||||
return await api.userInterviews.get(id)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
updateUserInterview: async (data: { summary: string }): Promise<UserInterviewType> => {
|
||||
return await api.userInterviews.update(props.id, data)
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
selectors(({ props }) => ({
|
||||
breadcrumbs: [
|
||||
(s) => [s.userInterview],
|
||||
(userInterview): Breadcrumb[] => [
|
||||
{
|
||||
key: 'UserInterviews',
|
||||
name: 'User interviews',
|
||||
path: urls.userInterviews(),
|
||||
},
|
||||
{
|
||||
key: props.id,
|
||||
name: userInterview?.interviewee_emails?.join(', '),
|
||||
path: urls.userInterview(props.id),
|
||||
},
|
||||
],
|
||||
],
|
||||
})),
|
||||
afterMount(({ actions, props }) => {
|
||||
if (props.id) {
|
||||
actions.loadUserInterview(props.id)
|
||||
}
|
||||
}),
|
||||
])
|
||||
36
products/user_interviews/frontend/userInterviewsLogic.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { afterMount, kea, path, selectors } from 'kea'
|
||||
import { loaders } from 'kea-loaders'
|
||||
import api from 'lib/api'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { Breadcrumb, UserInterviewType } from '~/types'
|
||||
|
||||
import type { userInterviewsLogicType } from './userInterviewsLogicType'
|
||||
|
||||
export const userInterviewsLogic = kea<userInterviewsLogicType>([
|
||||
path(['products', 'user_interviews', 'frontend', 'userInterviewsLogic']),
|
||||
loaders({
|
||||
userInterviews: {
|
||||
__default: [] as UserInterviewType[],
|
||||
loadUserInterviews: async () => {
|
||||
const response = await api.userInterviews.list()
|
||||
return response.results
|
||||
},
|
||||
},
|
||||
}),
|
||||
selectors({
|
||||
breadcrumbs: [
|
||||
() => [],
|
||||
(): Breadcrumb[] => [
|
||||
{
|
||||
key: 'UserInterviews',
|
||||
name: 'User interviews',
|
||||
path: urls.userInterviews(),
|
||||
},
|
||||
],
|
||||
],
|
||||
}),
|
||||
afterMount(({ actions }) => {
|
||||
actions.loadUserInterviews()
|
||||
}),
|
||||
])
|
||||
46
products/user_interviews/manifest.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { IconChat } from '@posthog/icons'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { ProductManifest } from '../../frontend/src/types'
|
||||
|
||||
export const manifest: ProductManifest = {
|
||||
name: 'User interviews',
|
||||
scenes: {
|
||||
UserInterviews: {
|
||||
name: 'User interviews',
|
||||
import: () => import('./frontend/UserInterviews'),
|
||||
projectBased: true,
|
||||
activityScope: 'UserInterview',
|
||||
},
|
||||
UserInterview: {
|
||||
name: 'User interview',
|
||||
import: () => import('./frontend/UserInterview'),
|
||||
projectBased: true,
|
||||
activityScope: 'UserInterview',
|
||||
},
|
||||
},
|
||||
routes: {
|
||||
'/user_interviews': ['UserInterviews', 'userInterviews'],
|
||||
'/user_interviews/:id': ['UserInterview', 'userInterview'],
|
||||
},
|
||||
urls: {
|
||||
userInterviews: (): string => '/user_interviews',
|
||||
userInterview: (id: string): string => `/user_interviews/${id}`,
|
||||
},
|
||||
fileSystemTypes: {
|
||||
user_interview: {
|
||||
icon: <IconChat />,
|
||||
href: (ref: string) => urls.userInterview(ref),
|
||||
},
|
||||
},
|
||||
treeItemsProducts: [
|
||||
{
|
||||
path: 'User interviews',
|
||||
href: urls.userInterviews(),
|
||||
type: 'user_interview',
|
||||
},
|
||||
],
|
||||
fileSystemFilterTypes: {
|
||||
user_interview: { name: 'User interviews' },
|
||||
},
|
||||
}
|
||||
55
products/user_interviews/migrations/0001_initial.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 4.2.18 on 2025-05-15 18:20
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import posthog.models.utils
|
||||
import products.user_interviews.backend.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("posthog", "0733_file_system_shortcut"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserInterview",
|
||||
fields=[
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
(
|
||||
"interviewee_emails",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
max_length=254,
|
||||
validators=[products.user_interviews.backend.models.EmailWithDisplayNameValidator()],
|
||||
),
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
("transcript", models.TextField(blank=True)),
|
||||
("summary", models.TextField(blank=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
("team", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.team")),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
0
products/user_interviews/migrations/__init__.py
Normal file
1
products/user_interviews/migrations/max_migration.txt
Normal file
@@ -0,0 +1 @@
|
||||
0001_initial
|
||||
13
products/user_interviews/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@posthog/products-user-interviews",
|
||||
"peerDependencies": {
|
||||
"@posthog/icons": "*",
|
||||
"@types/react": "*",
|
||||
"posthog-js": "*",
|
||||
"clsx": "*",
|
||||
"kea": "*",
|
||||
"kea-loaders": "*",
|
||||
"kea-router": "*",
|
||||
"react": "*"
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,7 @@ dependencies = [
|
||||
"pyyaml==6.0.1",
|
||||
"tenacity>=8.4.2",
|
||||
"chdb>=3.1.2",
|
||||
"elevenlabs>=1.58.1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -30,6 +30,7 @@ depends_on = [
|
||||
"products.early_access_features",
|
||||
"products.editor",
|
||||
"products.revenue_analytics",
|
||||
"products.user_interviews",
|
||||
]
|
||||
|
||||
[[modules]]
|
||||
|
||||
19
uv.lock
generated
@@ -1566,6 +1566,23 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/cd/84c44a5d435f6544e58a9b138305f59bca232157ae4ecb658f9787f87d1c/drf_spectacular-0.27.2-py3-none-any.whl", hash = "sha256:b1c04bf8b2fbbeaf6f59414b4ea448c8787aba4d32f76055c3b13335cf7ec37b", size = 102930 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elevenlabs"
|
||||
version = "1.58.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/83/fd165b38a69a4a40746926a908ea92e456a0e0dd5b6038836c9cc94a3487/elevenlabs-1.58.1.tar.gz", hash = "sha256:e9f723a528c1bbd80605e639e858f7a58f204860faa9417305a4083508c7c0fb", size = 185830 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/1f/95e2e56e6c139c497b4f1d2a546093e90cecbdf156766260f9220ba6c4f7/elevenlabs-1.58.1-py3-none-any.whl", hash = "sha256:2163054cb36b0aa70079f47ef7c046bf8668d5d183fd616b1c1c11d3996a50ce", size = 473568 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "et-xmlfile"
|
||||
version = "1.1.0"
|
||||
@@ -3719,6 +3736,7 @@ dependencies = [
|
||||
{ name = "drf-exceptions-hog" },
|
||||
{ name = "drf-extensions" },
|
||||
{ name = "drf-spectacular" },
|
||||
{ name = "elevenlabs" },
|
||||
{ name = "geoip2" },
|
||||
{ name = "google-cloud-bigquery" },
|
||||
{ name = "google-genai" },
|
||||
@@ -3917,6 +3935,7 @@ requires-dist = [
|
||||
{ name = "drf-exceptions-hog", specifier = "==0.4.0" },
|
||||
{ name = "drf-extensions", specifier = "==0.7.0" },
|
||||
{ name = "drf-spectacular", specifier = "==0.27.2" },
|
||||
{ name = "elevenlabs", specifier = ">=1.58.1" },
|
||||
{ name = "geoip2", specifier = "==4.6.0" },
|
||||
{ name = "google-cloud-bigquery", specifier = "==3.26" },
|
||||
{ name = "google-genai", specifier = "==1.10.0" },
|
||||
|
||||