dev.metalisp.survey/docs.org
2024-10-18 15:31:18 +02:00

31 KiB

dev.metalisp.survey Documentation

Assessment

User Requests

  GET http://localhost:8080/survey/1057733929973421737730
  GET http://localhost:8080/survey/252366393230629784388

Class Definition

  @startuml
  skinparam dpi 300
  package ml-survey/assessment {
          abstract class Assessment {
                  # results: List
                  # responses: List
                  + displayer: Displayer
                  + calculator: Calculator
                  + calc-results(): List
                  + results-html(): List
          }

          class SusAssessment extends Assessment {}
          class VisawiAssessment extends Assessment {}

          abstract class Displayer {
                  + generate-html(): List
          }

          class SusDisplayer extends Displayer {}
          class VisawiDisplayer extends Displayer {}

          SusAssessment *-- SusDisplayer
          VisawiAssessment *-- VisawiDisplayer

          abstract class Calculator {
                  + calc-results(): List
                  + group-stats(): List
          }

          class SusCalculator extends Calculator {}
          class VisawiCalculator extends Calculator {}

          SusAssessment *-- SusCalculator
          VisawiAssessment *-- VisawiCalculator
  }
  @enduml

/marcuskammer/dev.metalisp.survey/media/commit/d3746873aa3049ab81bf47cbb67bf839321f2676/class-diagram-assessment.png

The only functions which should be exported from the ml-survey/assessment package are:

assessment-results-html
Enables the caller to get the HTML for the right object.
parse-assessments
Returns a list of assessment objects created based on a PLIST of responses.
  (defpackage :ml-survey/assessment
    (:use #:cl)
    (:import-from #:ml-sbt/section
                  #:with-section
                  #:with-title-bar)
    (:import-from #:ml-sbt/tbl
                  #:render-tbl
                  #:render-kwlist-tbl
                  #:render-nested-plist-tbl)
    (:import-from #:ml-survey/stats
                  #:calculate-statistics
                  #:mean)
    (:export #:assessment-results-html
             #:parse-assessments))

  (in-package :ml-survey/assessment)

A assessment helps us to process the questionnaire responses and provides users with calculated results. To handle questionnaire responses we need to define a specific assessment for a specific questionnaire because questionnaires often needs specific statistical calculations. For instance, the calculations for a SUS is different from the calculations for a VISAWI.

  (defclass assessment ()
    ((results :initarg :results
              :reader assessment-results
              :type list
              :documentation "Complete output of an assessment.")
     (responses :initarg :responses
                :reader assessment-responses
                :type list
                :documentation "Data sent by the user using a HTML form.")
     (displayer :initarg :displayer
                :accessor assessment-displayer
                :type displayer)
     (calculator :initarg :calculator
                 :accessor assessment-calculator
                 :type calculator))
    (:documentation "Provides mechanism to handle data related to assessments. An assessment includes the calculated results for a specific questionnaire."))
  (defmethod assessment-calc-results ((a assessment))
    "Calculate results from responses."
    (let* ((responses (assessment-responses a))
           (calculator (assessment-calculator a))
           (results (calculator-calc-results calculator responses)))
      (make-instance (class-name (class-of a)) :results results)))
  (defmethod assessment-results-html ((a assessment))
    "Render HTML to show results."
    (let* ((results (assessment-results (assessment-calc-results a)))
           (group-stats (calculator-group-stats (assessment-calculator a) results)))
      (displayer-generate-html (assessment-displayer a) results group-stats)))

Helper Functions

  (defun string-to-keyword (str)
    "Converts string to keyword symbol.
  STR is a string.
  Returns keyword symbol."
    (intern (string-upcase str) :keyword))
  (defun string-integer (str)
    "Converts string to integer.
  STR is a string.
  Returns integer."
    (parse-integer (remove-if (complement #'digit-char-p) str)))

Questionnaire Responses

Response

  (defparameter %response-example-data%
    '(:TYPE "likert" :NAME "visawi" :TIMESTAMP "2024-08-18 12:03:52" :POST-DATA
      (("group-simplicity-1-r" . "2-stimme-nicht-zu")
       ("group-simplicity-2" . "2-stimme-nicht-zu")
       ("group-simplicity-3" . "3-stimme-eher-nicht-zu")
       ("group-simplicity-4-r" . "6-stimme-zu")
       ("group-simplicity-5" . "4-neutral")
       ("group-diversity-1-r" . "2-stimme-nicht-zu")
       ("group-diversity-2" . "6-stimme-zu")
       ("group-diversity-3-r" . "6-stimme-zu")
       ("group-diversity-4" . "5-stimme-eher-zu")
       ("group-diversity-5" . "1-stimme-gar-nicht-zu")
       ("group-colorfulness-1" . "4-neutral")
       ("group-colorfulness-2-r" . "4-neutral")
       ("group-colorfulness-3-r" . "6-stimme-zu")
       ("group-colorfulness-4" . "2-stimme-nicht-zu")
       ("group-craftsmanship-1" . "5-stimme-eher-zu")
       ("group-craftsmanship-2-r" . "1-stimme-gar-nicht-zu")
       ("group-craftsmanship-3" . "2-stimme-nicht-zu")
       ("group-craftsmanship-4-r" . "6-stimme-zu"))))
Entry
  (defparameter %entry-example-data%
    '("group-craftsmanship-4-r" . "6-stimme-zu"))
  (defun response-entry-extract-group (key-str)
    "Extract category name from response separated by hyphens.
  KEY-STR is a string.
  Returns string."
    (nth 1 (uiop:split-string key-str :separator "-")))
  (defun response-entry-negative-p (key-str)
    "Check if entry is a negtive question.
  KEY-STR is a string.
  Returns t or nil."
    (let ((identifier (first (last (uiop:split-string key-str :separator "-")))))
      (if (string= identifier "r")
          t
          nil)))
  (defun response-entry-process (value-fn entry)
    "Process entry and return category and value as (:CAT (VAL)).
  VALUE-FN: Function which operates on the value.
  ENTRY: Entry from a response."
    (destructuring-bind (key . value) entry
      (let* ((category (string-to-keyword (response-entry-extract-group key)))
             (negative-p (response-entry-negative-p key))
             (value (funcall value-fn (string-integer value) negative-p)))
        (list category (list value)))))

Interfaces

The following data structure is an example which put into parse-assessment

  (defparameter %categorized-responses-example%
    '(:VISAWI
      (("2024-10-10 10:42:01"
        ("group-simplicity-1-r" . "3-stimme-eher-nicht-zu")
        ("group-simplicity-2" . "5-stimme-eher-zu")
        ("group-simplicity-3" . "6-stimme-zu")
        ("group-simplicity-4-r" . "2-stimme-nicht-zu")
        ("group-simplicity-5" . "6-stimme-zu")
        ("group-diversity-1-r" . "1-stimme-gar-nicht-zu")
        ("group-diversity-2" . "5-stimme-eher-zu")
        ("group-diversity-3-r" . "5-stimme-eher-zu")
        ("group-diversity-4" . "1-stimme-gar-nicht-zu")
        ("group-diversity-5" . "6-stimme-zu")
        ("group-colorfulness-1" . "4-neutral")
        ("group-colorfulness-2-r" . "2-stimme-nicht-zu")
        ("group-colorfulness-3-r" . "5-stimme-eher-zu")
        ("group-colorfulness-4" . "5-stimme-eher-zu")
        ("group-craftsmanship-1" . "5-stimme-eher-zu")
        ("group-craftsmanship-2-r" . "3-stimme-eher-nicht-zu")
        ("group-craftsmanship-3" . "1-stimme-gar-nicht-zu")
        ("group-craftsmanship-4-r" . "4-neutral"))
       ("2024-10-10 10:41:34"
        ("group-simplicity-1-r" . "1-stimme-gar-nicht-zu")
        ("group-simplicity-2" . "3-stimme-eher-nicht-zu")
        ("group-simplicity-3" . "3-stimme-eher-nicht-zu")
        ("group-simplicity-4-r" . "7-stimme-voll-zu")
        ("group-simplicity-5" . "4-neutral")
        ("group-diversity-1-r" . "3-stimme-eher-nicht-zu")
        ("group-diversity-2" . "4-neutral")
        ("group-diversity-3-r" . "5-stimme-eher-zu")
        ("group-diversity-4" . "6-stimme-zu")
        ("group-diversity-5" . "2-stimme-nicht-zu")
        ("group-colorfulness-1" . "6-stimme-zu")
        ("group-colorfulness-2-r" . "2-stimme-nicht-zu")
        ("group-colorfulness-3-r" . "6-stimme-zu")
        ("group-colorfulness-4" . "3-stimme-eher-nicht-zu")
        ("group-craftsmanship-1" . "3-stimme-eher-nicht-zu")
        ("group-craftsmanship-2-r" . "1-stimme-gar-nicht-zu")
        ("group-craftsmanship-3" . "2-stimme-nicht-zu")
        ("group-craftsmanship-4-r" . "3-stimme-eher-nicht-zu")))
      :SUS
      (("2024-07-14 21:17:21" ("group-sus-1" . "1-strongly-disagree")
        ("group-sus-2" . "2-disagree") ("group-sus-3" . "4-agree")
        ("group-sus-4" . "3-neither-agree-nor-disagree")
        ("group-sus-5" . "1-strongly-disagree")
        ("group-sus-6" . "5-strongly-agree")
        ("group-sus-7" . "1-strongly-disagree") ("group-sus-8" . "2-disagree")
        ("group-sus-9" . "4-agree")
        ("group-sus-10" . "3-neither-agree-nor-disagree")))))
  (defun create-assessment (type responses)
    (ecase type
      (:visawi (make-instance 'visawi-assessment :responses responses))
      (:sus (make-instance 'sus-assessment :responses responses))))
  (defun parse-assessments (categorized-responses)
    (loop for (type data) on categorized-responses by #'cddr
          collect (create-assessment type data)))

Calculator

  (defclass calculator () ())

  (defgeneric calculator-calc-results (calculator responses))
  (defgeneric calculator-group-stats (calculator results))
SUS
  (defun sus-recode-values (values)
    (let ((counter 0))
      (mapcar (lambda (x)
                (setq counter (1+ counter))
                (if (evenp counter)
                    (- 5 x)
                    (1- x)))
              values)))
  (defun sus-values-with-score (values)
    (let ((recoded-values (sus-recode-values values)))
      (reverse (cons (* (apply #'+ recoded-values) 2.5) (reverse values)))))
  (defclass sus-calculator (calculator) ())

  (defmethod calculator-calc-results ((calc sus-calculator) responses)
    (loop :for response :in responses
          :for timestamp = (first response)
          :for extracted-values = (mapcar #'string-integer
                                          (mapcar #'cdr
                                                  (cdr response)))
          :collect (cons timestamp (sus-values-with-score extracted-values))))
  (defmethod calculator-group-stats ((calc sus-calculator) results)
    nil)
VISAWI
Aggregate Values Per Category

The following list shows an example of an response entry from the VISAWI questionnaire:

  (defparameter %example-visawi-response-entry%
    '(("group-simplicity-1-r" . "1-stimme-gar-nicht-zu")
      ("group-simplicity-2" . "3-stimme-eher-nicht-zu")
      ("group-simplicity-3" . "3-stimme-eher-nicht-zu")
      ("group-simplicity-4-r" . "7-stimme-voll-zu")
      ("group-simplicity-5" . "4-neutral")
      ("group-diversity-1-r" . "3-stimme-eher-nicht-zu")
      ("group-diversity-2" . "4-neutral")
      ("group-diversity-3-r" . "5-stimme-eher-zu")
      ("group-diversity-4" . "6-stimme-zu")
      ("group-diversity-5" . "2-stimme-nicht-zu")
      ("group-colorfulness-1" . "6-stimme-zu")
      ("group-colorfulness-2-r" . "2-stimme-nicht-zu")
      ("group-colorfulness-3-r" . "6-stimme-zu")
      ("group-colorfulness-4" . "3-stimme-eher-nicht-zu")
      ("group-craftsmanship-1" . "3-stimme-eher-nicht-zu")
      ("group-craftsmanship-2-r" . "1-stimme-gar-nicht-zu")
      ("group-craftsmanship-3" . "2-stimme-nicht-zu")
      ("group-craftsmanship-4-r" . "3-stimme-eher-nicht-zu")))

This list of key/value entries is then processed by visawi-entry-process to extract the category and the corresponding value. For the VISAWI we need to recode the value if it is related to a negative question.

  (defun visawi-value-matching (value)
  (case value
    (1 7)
    (2 6)
    (3 5)
    (4 4)
    (5 3)
    (6 2)
    (7 1)))
  (defun visawi-recode-value (value negative-p)
    "Recode response score from negative question.
  VALUE is a integer.
  NEGATIVE-P is a Predicate.
  Returns an integer."
    (if negative-p
        (visawi-value-matching value)
        value))
  (defun visawi-entry-process (entry)
    (response-entry-process #'visawi-recode-value entry))
  (mapcar #'visawi-entry-process %example-visawi-response-entry%)
((:SIMPLICITY (7)) (:SIMPLICITY (3)) (:SIMPLICITY (3)) (:SIMPLICITY (1))
 (:SIMPLICITY (4)) (:DIVERSITY (5)) (:DIVERSITY (4)) (:DIVERSITY (3))
 (:DIVERSITY (6)) (:DIVERSITY (2)) (:COLORFULNESS (6)) (:COLORFULNESS (6))
 (:COLORFULNESS (2)) (:COLORFULNESS (3)) (:CRAFTSMANSHIP (3))
 (:CRAFTSMANSHIP (7)) (:CRAFTSMANSHIP (2)) (:CRAFTSMANSHIP (5)))

After processing the raw response entries we get a plist with the value as list which is import for the next step where we want to merge the values per categories.

  (defun merge-values-into-group (acc new-value)
    (let ((cat-name (first new-value))
          (value (second new-value)))
      (setf (getf acc cat-name)
            (append (getf acc cat-name) value))
      acc))
  (defun aggregate-values-per-group (entry-fn responses)
    (reduce #'merge-values-into-group
            (mapcar entry-fn responses)
            :initial-value '()))

The result is a plist with aggregated values for every category.

  (defparameter %return-aggregate-values-per-group%
    '(:CRAFTSMANSHIP (3 7 2 5)
      :COLORFULNESS (6 6 2 3)
      :DIVERSITY (5 4 3 6 2)
      :SIMPLICITY (7 3 3 1 4)))
Calculate Mean Score Per Category
  (defun mean-score-per-group (entry-fn responses)
    (let* ((values-per-group (aggregate-values-per-group entry-fn responses))
           (mean-scores (loop for (category values) on values-per-group by #'cddr
                              collect category
                              collect (* 1.0 (mean values))))
           (overall-mean (* 1.0 (mean (loop for (nil score) on mean-scores by #'cddr
                                            collect score)))))
      (append mean-scores (list :overall overall-mean))))
Calc Results
  (defclass visawi-calculator (calculator) ())
  (defmethod calculator-calc-results ((calc visawi-calculator) responses)
    (loop :for response :in responses
          :for timestamp = (first response)
          :collect (cons :timestamp
                         (cons timestamp (mean-score-per-group #'visawi-entry-process
                                                           (rest response))))))
  (defparameter %example-calculator-calc-results%
    '((:TIMESTAMP "2024-10-10 10:42:01" :CRAFTSMANSHIP 3.75 :COLORFULNESS 4.5 :DIVERSITY 4.4 :SIMPLICITY 5.6 :OVERALL 4.5625)
      (:TIMESTAMP "2024-10-10 10:41:34" :CRAFTSMANSHIP 4.25 :COLORFULNESS 4.25 :DIVERSITY 4.0 :SIMPLICITY 3.6 :OVERALL 4.025)))
%EXAMPLE-CALCULATOR-CALC-RESULTS%
  (defmethod calculator-group-stats ((calc visawi-calculator) results)
    (if (> (length results) 1)
        (let* ((transposed-data (preprocess-and-transpose results))
               (dimensions '("simplicity" "diversity" "colorfulness" "craftsmanship" "mean"))
               (results (mapcar #'calculate-statistics transposed-data)))
          (mapcar #'cons dimensions results))
        nil))
  (defmethod calculator-group-stats ((calc visawi-calculator) results)
    (if (> (length results) 1)
        (flet ((merge-entry (acc entry)
                 (loop :for (key value) :on entry :by #'cddr
                       :do (push value (getf acc key)))
                 acc))
          (let ((merged-values (reduce #'merge-entry
                                       (mapcar #'cddr results)
                                       :initial-value '())))
            (reverse (loop :for (key value) :on merged-values :by #'cddr
                           :collect (list key (calculate-statistics value))))))
        nil))
((:CRAFTSMANSHIP (:MEDIAN 4.0 :MEAN 4.0 :SD 0.35355338 :MIN 3.75 :MAX 4.25))
 (:COLORFULNESS (:MEDIAN 4.375 :MEAN 4.375 :SD 0.17677669 :MIN 4.25 :MAX 4.5))
 (:DIVERSITY (:MEDIAN 4.2 :MEAN 4.2 :SD 0.2828428 :MIN 4.0 :MAX 4.4))
 (:SIMPLICITY (:MEDIAN 4.6 :MEAN 4.6 :SD 1.4142135 :MIN 3.6 :MAX 5.6))
 (:OVERALL
  (:MEDIAN 4.29375 :MEAN 4.29375 :SD 0.38006982 :MIN 4.025 :MAX 4.5625)))

Displayer

  (defclass displayer () ())
  (defgeneric displayer-generate-html (displayer results &optional group-stats))
  (defclass sus-displayer (displayer) ())
  (defmethod displayer-generate-html ((disp sus-displayer) results &optional group-stats)
    (with-section (with-title-bar "SUS Results")
      (render-tbl '("Timestamp" "Q1" "Q2" "Q3" "Q4" "Q5" "Q6" "Q7" "Q8" "Q9" "Q10" "Score")
                  results)))
  (defclass visawi-displayer (displayer) ())
  (defmethod displayer-generate-html ((disp visawi-displayer) results &optional group-stats)
    (with-section (with-title-bar "VISAWI Results")
        (with-section (with-title-bar "Individual Analysis")
          (render-kwlist-tbl results))
        (when group-stats
          (with-section (with-title-bar "Group Analysis")
            (render-nested-plist-tbl group-stats)))))

SUS Instance

  (defclass sus-assessment (assessment) ())

  (defmethod initialize-instance :after ((a sus-assessment) &key)
    (setf (assessment-calculator a) (make-instance 'sus-calculator)
          (assessment-displayer a) (make-instance 'sus-displayer)))

VISAWI Instance

  (defclass visawi-assessment (assessment) ())

  (defmethod initialize-instance :after ((a visawi-assessment) &key)
    (setf (assessment-calculator a) (make-instance 'visawi-calculator)
          (assessment-displayer a) (make-instance 'visawi-displayer)))